diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index b132f220889aa..5f04ae95d1598 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -10,8 +10,6 @@ on: - "flake.lock" - "flake.nix" pull_request: - branches-ignore: - - "dependabot/**" paths: - "dogfood/**" - ".github/workflows/dogfood.yaml" @@ -21,6 +19,7 @@ on: jobs: build_image: + if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs runs-on: ubuntu-latest steps: - name: Checkout diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b72db00912ff7..8fdcd232c0958 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1558,7 +1558,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "General" + "Notifications" ], "summary": "Get notifications settings", "operationId": "get-notifications-settings", @@ -1584,7 +1584,7 @@ const docTemplate = `{ "application/json" ], "tags": [ - "General" + "Notifications" ], "summary": "Update notifications settings", "operationId": "update-notifications-settings", @@ -1612,6 +1612,56 @@ const docTemplate = `{ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notifications" + ], + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, + "/notifications/templates/{notification_template}/method": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update notification template dispatch method", + "operationId": "post-notification-template-method", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -10199,6 +10249,36 @@ const docTemplate = `{ } } }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { @@ -11214,6 +11294,8 @@ const docTemplate = `{ "file", "group", "license", + "notification_preference", + "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", @@ -11242,6 +11324,8 @@ const docTemplate = `{ "ResourceFile", "ResourceGroup", "ResourceLicense", + "ResourceNotificationPreference", + "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", "ResourceOauth2AppSecret", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bf7216f1f313b..2abb9f262ddb1 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1352,7 +1352,7 @@ } ], "produces": ["application/json"], - "tags": ["General"], + "tags": ["Notifications"], "summary": "Get notifications settings", "operationId": "get-notifications-settings", "responses": { @@ -1372,7 +1372,7 @@ ], "consumes": ["application/json"], "produces": ["application/json"], - "tags": ["General"], + "tags": ["Notifications"], "summary": "Update notifications settings", "operationId": "update-notifications-settings", "parameters": [ @@ -1399,6 +1399,48 @@ } } }, + "/notifications/templates/system": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Notifications"], + "summary": "Get system notification templates", + "operationId": "get-system-notification-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.NotificationTemplate" + } + } + } + } + } + }, + "/notifications/templates/{notification_template}/method": { + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update notification template dispatch method", + "operationId": "post-notification-template-method", + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -9140,6 +9182,36 @@ } } }, + "codersdk.NotificationTemplate": { + "type": "object", + "properties": { + "actions": { + "type": "string" + }, + "body_template": { + "type": "string" + }, + "group": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string" + }, + "method": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title_template": { + "type": "string" + } + } + }, "codersdk.NotificationsConfig": { "type": "object", "properties": { @@ -10116,6 +10188,8 @@ "file", "group", "license", + "notification_preference", + "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret", @@ -10144,6 +10218,8 @@ "ResourceFile", "ResourceGroup", "ResourceLicense", + "ResourceNotificationPreference", + "ResourceNotificationTemplate", "ResourceOauth2App", "ResourceOauth2AppCodeToken", "ResourceOauth2AppSecret", diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 129b904c75b03..04943c760a55e 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -25,7 +25,8 @@ type Auditable interface { database.OAuth2ProviderAppSecret | database.CustomRole | database.AuditableOrganizationMember | - database.Organization + database.Organization | + database.NotificationTemplate } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 6c862c6e11103..adaf3ce1f573c 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -16,6 +16,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpmw" @@ -117,6 +118,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.Username case database.Organization: return typed.Name + case database.NotificationTemplate: + return typed.Name default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -163,6 +166,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.UserID case database.Organization: return typed.ID + case database.NotificationTemplate: + return typed.ID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -206,6 +211,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeOrganizationMember case database.Organization: return database.ResourceTypeOrganization + case database.NotificationTemplate: + return database.ResourceTypeNotificationTemplate default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -251,6 +258,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { return true case database.Organization: return true + case database.NotificationTemplate: + return false default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/coderd.go b/coderd/coderd.go index 6f8a59ad6efc6..b45e5219a74d0 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1243,9 +1243,15 @@ func New(options *Options) *API { }) }) r.Route("/notifications", func(r chi.Router) { - r.Use(apiKeyMiddleware) + r.Use( + apiKeyMiddleware, + httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentNotifications), + ) r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) + r.Route("/templates", func(r chi.Router) { + r.Get("/system", api.systemNotificationTemplates) + }) }) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b7cff64e2a57b..3e90598c61365 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1474,6 +1474,21 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } +func (q *querier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationTemplate); err != nil { + return database.NotificationTemplate{}, err + } + return q.db.GetNotificationTemplateByID(ctx, id) +} + +func (q *querier) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { + // TODO: restrict 'system' kind to admins only? + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationTemplate); err != nil { + return nil, err + } + return q.db.GetNotificationTemplatesByKind(ctx, kind) +} + func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) { // No authz checks return q.db.GetNotificationsSettings(ctx) @@ -2085,6 +2100,13 @@ func (q *querier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([ return q.db.GetUserLinksByUserID(ctx, userID) } +func (q *querier) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationPreference.WithOwner(userID.String())); err != nil { + return nil, err + } + return q.db.GetUserNotificationPreferences(ctx, userID) +} + func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { u, err := q.db.GetUserByID(ctx, params.OwnerID) if err != nil { @@ -3011,6 +3033,13 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb return q.db.UpdateMemberRoles(ctx, arg) } +func (q *querier) UpdateNotificationTemplateMethodByID(ctx context.Context, arg database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationTemplate); err != nil { + return database.NotificationTemplate{}, err + } + return q.db.UpdateNotificationTemplateMethodByID(ctx, arg) +} + func (q *querier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -3309,6 +3338,13 @@ func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUs return q.db.UpdateUserLoginType(ctx, arg) } +func (q *querier) UpdateUserNotificationPreferences(ctx context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceNotificationPreference.WithOwner(arg.UserID.String())); err != nil { + return -1, err + } + return q.db.UpdateUserNotificationPreferences(ctx, arg) +} + func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { u, err := q.db.GetUserByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 876c0d797f64a..8b579c4482cf7 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -16,6 +16,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" @@ -2555,6 +2556,10 @@ func (s *MethodTestSuite) TestSystemFunctions() { AgentID: uuid.New(), }).Asserts(tpl, policy.ActionCreate) })) +} + +func (s *MethodTestSuite) TestNotifications() { + // System functions s.Run("AcquireNotificationMessages", s.Subtest(func(db database.Store, check *expects) { // TODO: update this test once we have a specific role for notifications check.Args(database.AcquireNotificationMessagesParams{}).Asserts(rbac.ResourceSystem, policy.ActionUpdate) @@ -2590,6 +2595,40 @@ func (s *MethodTestSuite) TestSystemFunctions() { Limit: 10, }).Asserts(rbac.ResourceSystem, policy.ActionRead) })) + + // Notification templates + s.Run("GetNotificationTemplateByID", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID).Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead). + Errors(dbmem.ErrUnimplemented) + })) + s.Run("GetNotificationTemplatesByKind", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.NotificationTemplateKindSystem). + Asserts(rbac.ResourceNotificationTemplate, policy.ActionRead). + Errors(dbmem.ErrUnimplemented) + })) + s.Run("UpdateNotificationTemplateMethodByID", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpdateNotificationTemplateMethodByIDParams{ + Method: database.NullNotificationMethod{NotificationMethod: database.NotificationMethodWebhook, Valid: true}, + ID: notifications.TemplateWorkspaceDormant, + }).Asserts(rbac.ResourceNotificationTemplate, policy.ActionUpdate). + Errors(dbmem.ErrUnimplemented) + })) + + // Notification preferences + s.Run("GetUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(user.ID). + Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionRead) + })) + s.Run("UpdateUserNotificationPreferences", s.Subtest(func(db database.Store, check *expects) { + user := dbgen.User(s.T(), db, database.User{}) + check.Args(database.UpdateUserNotificationPreferencesParams{ + UserID: user.ID, + NotificationTemplateIds: []uuid.UUID{notifications.TemplateWorkspaceAutoUpdated, notifications.TemplateWorkspaceDeleted}, + Disableds: []bool{true, false}, + }).Asserts(rbac.ResourceNotificationPreference.WithOwner(user.ID.String()), policy.ActionUpdate) + })) } func (s *MethodTestSuite) TestOAuth2ProviderApps() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index f32de78b72714..d8782fc5811cc 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -65,6 +65,7 @@ func New() database.Store { files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), notificationMessages: make([]database.NotificationMessage, 0), + notificationPreferences: make([]database.NotificationPreference, 0), parameterSchemas: make([]database.ParameterSchema, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), @@ -160,6 +161,7 @@ type data struct { jfrogXRayScans []database.JfrogXrayScan licenses []database.License notificationMessages []database.NotificationMessage + notificationPreferences []database.NotificationPreference oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret oauth2ProviderAppCodes []database.OAuth2ProviderAppCode @@ -2708,6 +2710,14 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } +func (*FakeQuerier) GetNotificationTemplateByID(_ context.Context, _ uuid.UUID) (database.NotificationTemplate, error) { + return database.NotificationTemplate{}, ErrUnimplemented +} + +func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { + return nil, ErrUnimplemented +} + func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -4853,6 +4863,22 @@ func (q *FakeQuerier) GetUserLinksByUserID(_ context.Context, userID uuid.UUID) return uls, nil } +func (q *FakeQuerier) GetUserNotificationPreferences(_ context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + out := make([]database.NotificationPreference, 0, len(q.notificationPreferences)) + for _, np := range q.notificationPreferences { + if np.UserID != userID { + continue + } + + out = append(out, np) + } + + return out, nil +} + func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -7520,6 +7546,10 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe return database.OrganizationMember{}, sql.ErrNoRows } +func (*FakeQuerier) UpdateNotificationTemplateMethodByID(_ context.Context, _ database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) { + return database.NotificationTemplate{}, ErrUnimplemented +} + func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { err := validateDatabaseType(arg) if err != nil { @@ -8094,6 +8124,57 @@ func (q *FakeQuerier) UpdateUserLoginType(_ context.Context, arg database.Update return database.User{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateUserNotificationPreferences(_ context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) { + err := validateDatabaseType(arg) + if err != nil { + return -1, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + var upserted int64 + for i := range arg.NotificationTemplateIds { + var ( + found bool + templateID = arg.NotificationTemplateIds[i] + disabled = arg.Disableds[i] + ) + + for j, np := range q.notificationPreferences { + if np.UserID != arg.UserID { + continue + } + + if np.NotificationTemplateID != templateID { + continue + } + + np.Disabled = disabled + np.UpdatedAt = time.Now() + q.notificationPreferences[j] = np + + upserted++ + found = true + break + } + + if !found { + np := database.NotificationPreference{ + Disabled: disabled, + UserID: arg.UserID, + NotificationTemplateID: templateID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + q.notificationPreferences = append(q.notificationPreferences, np) + upserted++ + } + } + + return upserted, nil +} + func (q *FakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUserProfileParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 2b25591568f8c..b3e5ee86528bd 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -746,6 +746,20 @@ func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg d return r0, r1 } +func (m metricsStore) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationTemplateByID(ctx, id) + m.queryLatencies.WithLabelValues("GetNotificationTemplateByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetNotificationTemplatesByKind(ctx context.Context, kind database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationTemplatesByKind(ctx, kind) + m.queryLatencies.WithLabelValues("GetNotificationTemplatesByKind").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetNotificationsSettings(ctx context.Context) (string, error) { start := time.Now() r0, r1 := m.s.GetNotificationsSettings(ctx) @@ -1222,6 +1236,13 @@ func (m metricsStore) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID return r0, r1 } +func (m metricsStore) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]database.NotificationPreference, error) { + start := time.Now() + r0, r1 := m.s.GetUserNotificationPreferences(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserNotificationPreferences").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, ownerID database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { start := time.Now() r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, ownerID) @@ -1957,6 +1978,13 @@ func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.Update return member, err } +func (m metricsStore) UpdateNotificationTemplateMethodByID(ctx context.Context, arg database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) { + start := time.Now() + r0, r1 := m.s.UpdateNotificationTemplateMethodByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateNotificationTemplateMethodByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateOAuth2ProviderAppByID(ctx context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.UpdateOAuth2ProviderAppByID(ctx, arg) @@ -2132,6 +2160,13 @@ func (m metricsStore) UpdateUserLoginType(ctx context.Context, arg database.Upda return r0, r1 } +func (m metricsStore) UpdateUserNotificationPreferences(ctx context.Context, arg database.UpdateUserNotificationPreferencesParams) (int64, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserNotificationPreferences(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserNotificationPreferences").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.UpdateUserProfileParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserProfile(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b91ba6c8bd5d8..372dc0a0d631a 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1495,6 +1495,36 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1) } +// GetNotificationTemplateByID mocks base method. +func (m *MockStore) GetNotificationTemplateByID(arg0 context.Context, arg1 uuid.UUID) (database.NotificationTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationTemplateByID", arg0, arg1) + ret0, _ := ret[0].(database.NotificationTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationTemplateByID indicates an expected call of GetNotificationTemplateByID. +func (mr *MockStoreMockRecorder) GetNotificationTemplateByID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplateByID", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplateByID), arg0, arg1) +} + +// GetNotificationTemplatesByKind mocks base method. +func (m *MockStore) GetNotificationTemplatesByKind(arg0 context.Context, arg1 database.NotificationTemplateKind) ([]database.NotificationTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationTemplatesByKind", arg0, arg1) + ret0, _ := ret[0].([]database.NotificationTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationTemplatesByKind indicates an expected call of GetNotificationTemplatesByKind. +func (mr *MockStoreMockRecorder) GetNotificationTemplatesByKind(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationTemplatesByKind", reflect.TypeOf((*MockStore)(nil).GetNotificationTemplatesByKind), arg0, arg1) +} + // GetNotificationsSettings mocks base method. func (m *MockStore) GetNotificationsSettings(arg0 context.Context) (string, error) { m.ctrl.T.Helper() @@ -2545,6 +2575,21 @@ func (mr *MockStoreMockRecorder) GetUserLinksByUserID(arg0, arg1 any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetUserLinksByUserID), arg0, arg1) } +// GetUserNotificationPreferences mocks base method. +func (m *MockStore) GetUserNotificationPreferences(arg0 context.Context, arg1 uuid.UUID) ([]database.NotificationPreference, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserNotificationPreferences", arg0, arg1) + ret0, _ := ret[0].([]database.NotificationPreference) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserNotificationPreferences indicates an expected call of GetUserNotificationPreferences. +func (mr *MockStoreMockRecorder) GetUserNotificationPreferences(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).GetUserNotificationPreferences), arg0, arg1) +} + // GetUserWorkspaceBuildParameters mocks base method. func (m *MockStore) GetUserWorkspaceBuildParameters(arg0 context.Context, arg1 database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { m.ctrl.T.Helper() @@ -4131,6 +4176,21 @@ func (mr *MockStoreMockRecorder) UpdateMemberRoles(arg0, arg1 any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateMemberRoles", reflect.TypeOf((*MockStore)(nil).UpdateMemberRoles), arg0, arg1) } +// UpdateNotificationTemplateMethodByID mocks base method. +func (m *MockStore) UpdateNotificationTemplateMethodByID(arg0 context.Context, arg1 database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateNotificationTemplateMethodByID", arg0, arg1) + ret0, _ := ret[0].(database.NotificationTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateNotificationTemplateMethodByID indicates an expected call of UpdateNotificationTemplateMethodByID. +func (mr *MockStoreMockRecorder) UpdateNotificationTemplateMethodByID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNotificationTemplateMethodByID", reflect.TypeOf((*MockStore)(nil).UpdateNotificationTemplateMethodByID), arg0, arg1) +} + // UpdateOAuth2ProviderAppByID mocks base method. func (m *MockStore) UpdateOAuth2ProviderAppByID(arg0 context.Context, arg1 database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -4490,6 +4550,21 @@ func (mr *MockStoreMockRecorder) UpdateUserLoginType(arg0, arg1 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserLoginType", reflect.TypeOf((*MockStore)(nil).UpdateUserLoginType), arg0, arg1) } +// UpdateUserNotificationPreferences mocks base method. +func (m *MockStore) UpdateUserNotificationPreferences(arg0 context.Context, arg1 database.UpdateUserNotificationPreferencesParams) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserNotificationPreferences", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserNotificationPreferences indicates an expected call of UpdateUserNotificationPreferences. +func (mr *MockStoreMockRecorder) UpdateUserNotificationPreferences(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserNotificationPreferences", reflect.TypeOf((*MockStore)(nil).UpdateUserNotificationPreferences), arg0, arg1) +} + // UpdateUserProfile mocks base method. func (m *MockStore) UpdateUserProfile(arg0 context.Context, arg1 database.UpdateUserProfileParams) (database.User, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dc15cf9bd4af8..a7445b6b0401b 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -84,7 +84,8 @@ CREATE TYPE notification_message_status AS ENUM ( 'sent', 'permanent_failure', 'temporary_failure', - 'unknown' + 'unknown', + 'inhibited' ); CREATE TYPE notification_method AS ENUM ( @@ -92,6 +93,10 @@ CREATE TYPE notification_method AS ENUM ( 'webhook' ); +CREATE TYPE notification_template_kind AS ENUM ( + 'system' +); + CREATE TYPE parameter_destination_scheme AS ENUM ( 'none', 'environment_variable', @@ -164,7 +169,8 @@ CREATE TYPE resource_type AS ENUM ( 'oauth2_provider_app_secret', 'custom_role', 'organization_member', - 'notifications_settings' + 'notifications_settings', + 'notification_template' ); CREATE TYPE startup_script_behavior AS ENUM ( @@ -249,6 +255,23 @@ BEGIN END; $$; +CREATE FUNCTION inhibit_enqueue_if_disabled() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + -- Fail the insertion if the user has disabled this notification. + IF EXISTS (SELECT 1 + FROM notification_preferences + WHERE disabled = TRUE + AND user_id = NEW.user_id + AND notification_template_id = NEW.notification_template_id) THEN + RAISE EXCEPTION 'cannot enqueue message: user has disabled this notification'; + END IF; + + RETURN NEW; +END; +$$; + CREATE FUNCTION insert_apikey_fail_if_user_deleted() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -567,17 +590,29 @@ CREATE TABLE notification_messages ( queued_seconds double precision ); +CREATE TABLE notification_preferences ( + user_id uuid NOT NULL, + notification_template_id uuid NOT NULL, + disabled boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + CREATE TABLE notification_templates ( id uuid NOT NULL, name text NOT NULL, title_template text NOT NULL, body_template text NOT NULL, actions jsonb, - "group" text + "group" text, + method notification_method, + kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL ); COMMENT ON TABLE notification_templates IS 'Templates from which to create notification messages.'; +COMMENT ON COLUMN notification_templates.method IS 'NULL defers to the deployment-level method'; + CREATE TABLE oauth2_provider_app_codes ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -1533,6 +1568,9 @@ ALTER TABLE ONLY licenses ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); +ALTER TABLE ONLY notification_preferences + ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); + ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); @@ -1638,6 +1676,9 @@ ALTER TABLE ONLY template_versions ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); +ALTER TABLE ONLY notification_preferences + ADD CONSTRAINT unique_user_notification_template UNIQUE (user_id, notification_template_id); + ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); @@ -1795,6 +1836,8 @@ CREATE INDEX workspace_resources_job_id_idx ON workspace_resources USING btree ( CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false); +CREATE TRIGGER inhibit_enqueue_if_disabled BEFORE INSERT ON notification_messages FOR EACH ROW EXECUTE FUNCTION inhibit_enqueue_if_disabled(); + CREATE TRIGGER tailnet_notify_agent_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_agents FOR EACH ROW EXECUTE FUNCTION tailnet_notify_agent_change(); CREATE TRIGGER tailnet_notify_client_change AFTER INSERT OR DELETE OR UPDATE ON tailnet_clients FOR EACH ROW EXECUTE FUNCTION tailnet_notify_client_change(); @@ -1848,6 +1891,12 @@ ALTER TABLE ONLY notification_messages ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY notification_preferences + ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + +ALTER TABLE ONLY notification_preferences + ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 6e6eef8862b72..011d39bdc5b91 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -17,6 +17,8 @@ const ( ForeignKeyJfrogXrayScansWorkspaceID ForeignKeyConstraint = "jfrog_xray_scans_workspace_id_fkey" // ALTER TABLE ONLY jfrog_xray_scans ADD CONSTRAINT jfrog_xray_scans_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE; ForeignKeyNotificationMessagesNotificationTemplateID ForeignKeyConstraint = "notification_messages_notification_template_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; ForeignKeyNotificationMessagesUserID ForeignKeyConstraint = "notification_messages_user_id_fkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ForeignKeyNotificationPreferencesNotificationTemplateID ForeignKeyConstraint = "notification_preferences_notification_template_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_notification_template_id_fkey FOREIGN KEY (notification_template_id) REFERENCES notification_templates(id) ON DELETE CASCADE; + ForeignKeyNotificationPreferencesUserID ForeignKeyConstraint = "notification_preferences_user_id_fkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesAppID ForeignKeyConstraint = "oauth2_provider_app_codes_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppCodesUserID ForeignKeyConstraint = "oauth2_provider_app_codes_user_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyOauth2ProviderAppSecretsAppID ForeignKeyConstraint = "oauth2_provider_app_secrets_app_id_fkey" // ALTER TABLE ONLY oauth2_provider_app_secrets ADD CONSTRAINT oauth2_provider_app_secrets_app_id_fkey FOREIGN KEY (app_id) REFERENCES oauth2_provider_apps(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000234_fix_notifications_user_created.down.sql b/coderd/database/migrations/000234_fix_notifications_user_created.down.sql new file mode 100644 index 0000000000000..526b9aef53e5a --- /dev/null +++ b/coderd/database/migrations/000234_fix_notifications_user_created.down.sql @@ -0,0 +1,5 @@ +UPDATE notification_templates +SET + body_template = E'Hi {{.UserName}},\n\New user account **{{.Labels.created_account_name}}** has been created.' +WHERE + id = '4e19c0ac-94e1-4532-9515-d1801aa283b2'; diff --git a/coderd/database/migrations/000234_fix_notifications_user_created.up.sql b/coderd/database/migrations/000234_fix_notifications_user_created.up.sql new file mode 100644 index 0000000000000..5fb59dbd2ecdf --- /dev/null +++ b/coderd/database/migrations/000234_fix_notifications_user_created.up.sql @@ -0,0 +1,5 @@ +UPDATE notification_templates +SET + body_template = E'Hi {{.UserName}},\n\nNew user account **{{.Labels.created_account_name}}** has been created.' +WHERE + id = '4e19c0ac-94e1-4532-9515-d1801aa283b2'; diff --git a/coderd/database/migrations/000234_notification_preferences.down.sql b/coderd/database/migrations/000234_notification_preferences.down.sql new file mode 100644 index 0000000000000..5e894d93e5289 --- /dev/null +++ b/coderd/database/migrations/000234_notification_preferences.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE notification_templates + DROP COLUMN IF EXISTS method, + DROP COLUMN IF EXISTS kind; + +DROP TABLE IF EXISTS notification_preferences; +DROP TYPE IF EXISTS notification_template_kind; + +DROP TRIGGER IF EXISTS inhibit_enqueue_if_disabled ON notification_messages; +DROP FUNCTION IF EXISTS inhibit_enqueue_if_disabled; diff --git a/coderd/database/migrations/000234_notification_preferences.up.sql b/coderd/database/migrations/000234_notification_preferences.up.sql new file mode 100644 index 0000000000000..417786d2fe6ff --- /dev/null +++ b/coderd/database/migrations/000234_notification_preferences.up.sql @@ -0,0 +1,56 @@ +CREATE TABLE notification_preferences +( + user_id uuid REFERENCES users ON DELETE CASCADE NOT NULL, + notification_template_id uuid REFERENCES notification_templates ON DELETE CASCADE NOT NULL, + disabled bool NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, notification_template_id) +); + +-- Ensure we cannot insert multiple entries for the same user/template combination. +ALTER TABLE notification_preferences + ADD CONSTRAINT unique_user_notification_template UNIQUE (user_id, notification_template_id); + +-- Add a new type (to be expanded upon later) which specifies the kind of notification template. +CREATE TYPE notification_template_kind AS ENUM ( + 'system' + ); + +ALTER TABLE notification_templates + -- Allow per-template notification method (enterprise only). + ADD COLUMN method notification_method, + -- Update all existing notification templates to be system templates. + ADD COLUMN kind notification_template_kind DEFAULT 'system'::notification_template_kind NOT NULL; +COMMENT ON COLUMN notification_templates.method IS 'NULL defers to the deployment-level method'; + +-- No equivalent in down migration because ENUM values cannot be deleted. +ALTER TYPE notification_message_status ADD VALUE IF NOT EXISTS 'inhibited'; + +-- Function to prevent enqueuing notifications unnecessarily. +CREATE OR REPLACE FUNCTION inhibit_enqueue_if_disabled() + RETURNS TRIGGER AS +$$ +BEGIN + -- Fail the insertion if the user has disabled this notification. + IF EXISTS (SELECT 1 + FROM notification_preferences + WHERE disabled = TRUE + AND user_id = NEW.user_id + AND notification_template_id = NEW.notification_template_id) THEN + RAISE EXCEPTION 'cannot enqueue message: user has disabled this notification'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to execute above function on insertion. +CREATE TRIGGER inhibit_enqueue_if_disabled + BEFORE INSERT + ON notification_messages + FOR EACH ROW +EXECUTE FUNCTION inhibit_enqueue_if_disabled(); + +-- Allow modifications to notification templates to be audited. +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'notification_template'; diff --git a/coderd/database/migrations/testdata/fixtures/000234_notifications_preferences.up.sql b/coderd/database/migrations/testdata/fixtures/000234_notifications_preferences.up.sql new file mode 100644 index 0000000000000..5795ca15dc5f8 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000234_notifications_preferences.up.sql @@ -0,0 +1,9 @@ +INSERT INTO users(id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, deleted) +VALUES ('fc1511ef-4fcf-4a3b-98a1-8df64160e35a', 'githubuser@coder.com', 'githubuser', '\x', + '2022-11-02 13:05:21.445455+02', '2022-11-02 13:05:21.445455+02', 'active', '{}', false) ON CONFLICT DO NOTHING; + +INSERT INTO notification_templates (id, name, title_template, body_template, "group") +VALUES ('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'A', 'title', 'body', 'Group 1') ON CONFLICT DO NOTHING; + +INSERT INTO notification_preferences (user_id, notification_template_id, disabled, created_at, updated_at) +VALUES ('a0061a8e-7db7-4585-838c-3116a003dd21', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', FALSE, '2024-07-15 10:30:00+00', '2024-07-15 10:30:00+00'); diff --git a/coderd/database/models.go b/coderd/database/models.go index 0ee78e286516e..ab12f5284b0cd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -669,6 +669,7 @@ const ( NotificationMessageStatusPermanentFailure NotificationMessageStatus = "permanent_failure" NotificationMessageStatusTemporaryFailure NotificationMessageStatus = "temporary_failure" NotificationMessageStatusUnknown NotificationMessageStatus = "unknown" + NotificationMessageStatusInhibited NotificationMessageStatus = "inhibited" ) func (e *NotificationMessageStatus) Scan(src interface{}) error { @@ -713,7 +714,8 @@ func (e NotificationMessageStatus) Valid() bool { NotificationMessageStatusSent, NotificationMessageStatusPermanentFailure, NotificationMessageStatusTemporaryFailure, - NotificationMessageStatusUnknown: + NotificationMessageStatusUnknown, + NotificationMessageStatusInhibited: return true } return false @@ -727,6 +729,7 @@ func AllNotificationMessageStatusValues() []NotificationMessageStatus { NotificationMessageStatusPermanentFailure, NotificationMessageStatusTemporaryFailure, NotificationMessageStatusUnknown, + NotificationMessageStatusInhibited, } } @@ -788,6 +791,61 @@ func AllNotificationMethodValues() []NotificationMethod { } } +type NotificationTemplateKind string + +const ( + NotificationTemplateKindSystem NotificationTemplateKind = "system" +) + +func (e *NotificationTemplateKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = NotificationTemplateKind(s) + case string: + *e = NotificationTemplateKind(s) + default: + return fmt.Errorf("unsupported scan type for NotificationTemplateKind: %T", src) + } + return nil +} + +type NullNotificationTemplateKind struct { + NotificationTemplateKind NotificationTemplateKind `json:"notification_template_kind"` + Valid bool `json:"valid"` // Valid is true if NotificationTemplateKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullNotificationTemplateKind) Scan(value interface{}) error { + if value == nil { + ns.NotificationTemplateKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.NotificationTemplateKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullNotificationTemplateKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.NotificationTemplateKind), nil +} + +func (e NotificationTemplateKind) Valid() bool { + switch e { + case NotificationTemplateKindSystem: + return true + } + return false +} + +func AllNotificationTemplateKindValues() []NotificationTemplateKind { + return []NotificationTemplateKind{ + NotificationTemplateKindSystem, + } +} + type ParameterDestinationScheme string const ( @@ -1353,6 +1411,7 @@ const ( ResourceTypeCustomRole ResourceType = "custom_role" ResourceTypeOrganizationMember ResourceType = "organization_member" ResourceTypeNotificationsSettings ResourceType = "notifications_settings" + ResourceTypeNotificationTemplate ResourceType = "notification_template" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1409,7 +1468,8 @@ func (e ResourceType) Valid() bool { ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, ResourceTypeOrganizationMember, - ResourceTypeNotificationsSettings: + ResourceTypeNotificationsSettings, + ResourceTypeNotificationTemplate: return true } return false @@ -1435,6 +1495,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeCustomRole, ResourceTypeOrganizationMember, ResourceTypeNotificationsSettings, + ResourceTypeNotificationTemplate, } } @@ -2034,6 +2095,14 @@ type NotificationMessage struct { QueuedSeconds sql.NullFloat64 `db:"queued_seconds" json:"queued_seconds"` } +type NotificationPreference struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"` + Disabled bool `db:"disabled" json:"disabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // Templates from which to create notification messages. type NotificationTemplate struct { ID uuid.UUID `db:"id" json:"id"` @@ -2042,6 +2111,9 @@ type NotificationTemplate struct { BodyTemplate string `db:"body_template" json:"body_template"` Actions []byte `db:"actions" json:"actions"` Group sql.NullString `db:"group" json:"group"` + // NULL defers to the deployment-level method + Method NullNotificationMethod `db:"method" json:"method"` + Kind NotificationTemplateKind `db:"kind" json:"kind"` } // A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 9d0494813e306..bbc2983fe62fb 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -162,6 +162,8 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) + GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) + GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) GetNotificationsSettings(ctx context.Context) (string, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) @@ -265,6 +267,7 @@ type sqlcQuerier interface { GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) + GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) @@ -401,6 +404,7 @@ type sqlcQuerier interface { UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) + UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error) UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) @@ -426,6 +430,7 @@ type sqlcQuerier interface { UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error) UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) + UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 904f304bd25a9..3813200b4627b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3334,14 +3334,18 @@ SELECT nm.id, nm.payload, nm.method, - nm.attempt_count::int AS attempt_count, - nm.queued_seconds::float AS queued_seconds, + nm.attempt_count::int AS attempt_count, + nm.queued_seconds::float AS queued_seconds, -- template - nt.id AS template_id, + nt.id AS template_id, nt.title_template, - nt.body_template + nt.body_template, + -- preferences + (CASE WHEN np.disabled IS NULL THEN false ELSE np.disabled END)::bool AS disabled FROM acquired nm JOIN notification_templates nt ON nm.notification_template_id = nt.id + LEFT JOIN notification_preferences AS np + ON (np.user_id = nm.user_id AND np.notification_template_id = nm.notification_template_id) ` type AcquireNotificationMessagesParams struct { @@ -3360,6 +3364,7 @@ type AcquireNotificationMessagesRow struct { TemplateID uuid.UUID `db:"template_id" json:"template_id"` TitleTemplate string `db:"title_template" json:"title_template"` BodyTemplate string `db:"body_template" json:"body_template"` + Disabled bool `db:"disabled" json:"disabled"` } // Acquires the lease for a given count of notification messages, to enable concurrent dequeuing and subsequent sending. @@ -3395,6 +3400,7 @@ func (q *sqlQuerier) AcquireNotificationMessages(ctx context.Context, arg Acquir &i.TemplateID, &i.TitleTemplate, &i.BodyTemplate, + &i.Disabled, ); err != nil { return nil, err } @@ -3573,7 +3579,10 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe } const getNotificationMessagesByStatus = `-- name: GetNotificationMessagesByStatus :many -SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds FROM notification_messages WHERE status = $1 LIMIT $2::int +SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds +FROM notification_messages +WHERE status = $1 +LIMIT $2::int ` type GetNotificationMessagesByStatusParams struct { @@ -3620,6 +3629,153 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge return items, nil } +const getNotificationTemplateByID = `-- name: GetNotificationTemplateByID :one +SELECT id, name, title_template, body_template, actions, "group", method, kind +FROM notification_templates +WHERE id = $1::uuid +` + +func (q *sqlQuerier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) { + row := q.db.QueryRowContext(ctx, getNotificationTemplateByID, id) + var i NotificationTemplate + err := row.Scan( + &i.ID, + &i.Name, + &i.TitleTemplate, + &i.BodyTemplate, + &i.Actions, + &i.Group, + &i.Method, + &i.Kind, + ) + return i, err +} + +const getNotificationTemplatesByKind = `-- name: GetNotificationTemplatesByKind :many +SELECT id, name, title_template, body_template, actions, "group", method, kind FROM notification_templates +WHERE kind = $1::notification_template_kind +` + +func (q *sqlQuerier) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) { + rows, err := q.db.QueryContext(ctx, getNotificationTemplatesByKind, kind) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationTemplate + for rows.Next() { + var i NotificationTemplate + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TitleTemplate, + &i.BodyTemplate, + &i.Actions, + &i.Group, + &i.Method, + &i.Kind, + ); 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 getUserNotificationPreferences = `-- name: GetUserNotificationPreferences :many +SELECT user_id, notification_template_id, disabled, created_at, updated_at +FROM notification_preferences +WHERE user_id = $1::uuid +` + +func (q *sqlQuerier) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) { + rows, err := q.db.QueryContext(ctx, getUserNotificationPreferences, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationPreference + for rows.Next() { + var i NotificationPreference + if err := rows.Scan( + &i.UserID, + &i.NotificationTemplateID, + &i.Disabled, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 updateNotificationTemplateMethodByID = `-- name: UpdateNotificationTemplateMethodByID :one +UPDATE notification_templates +SET method = $1::notification_method +WHERE id = $2::uuid +RETURNING id, name, title_template, body_template, actions, "group", method, kind +` + +type UpdateNotificationTemplateMethodByIDParams struct { + Method NullNotificationMethod `db:"method" json:"method"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateNotificationTemplateMethodByID(ctx context.Context, arg UpdateNotificationTemplateMethodByIDParams) (NotificationTemplate, error) { + row := q.db.QueryRowContext(ctx, updateNotificationTemplateMethodByID, arg.Method, arg.ID) + var i NotificationTemplate + err := row.Scan( + &i.ID, + &i.Name, + &i.TitleTemplate, + &i.BodyTemplate, + &i.Actions, + &i.Group, + &i.Method, + &i.Kind, + ) + return i, err +} + +const updateUserNotificationPreferences = `-- name: UpdateUserNotificationPreferences :execrows +INSERT +INTO notification_preferences (user_id, notification_template_id, disabled) +SELECT $1::uuid, new_values.notification_template_id, new_values.disabled +FROM (SELECT UNNEST($2::uuid[]) AS notification_template_id, + UNNEST($3::bool[]) AS disabled) AS new_values +ON CONFLICT (user_id, notification_template_id) DO UPDATE + SET disabled = EXCLUDED.disabled, + updated_at = CURRENT_TIMESTAMP +` + +type UpdateUserNotificationPreferencesParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + NotificationTemplateIds []uuid.UUID `db:"notification_template_ids" json:"notification_template_ids"` + Disableds []bool `db:"disableds" json:"disableds"` +} + +func (q *sqlQuerier) UpdateUserNotificationPreferences(ctx context.Context, arg UpdateUserNotificationPreferencesParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateUserNotificationPreferences, arg.UserID, pq.Array(arg.NotificationTemplateIds), pq.Array(arg.Disableds)) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec DELETE FROM oauth2_provider_apps WHERE id = $1 ` diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index c0a2f25323957..d62564ea6edbf 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -79,14 +79,18 @@ SELECT nm.id, nm.payload, nm.method, - nm.attempt_count::int AS attempt_count, - nm.queued_seconds::float AS queued_seconds, + nm.attempt_count::int AS attempt_count, + nm.queued_seconds::float AS queued_seconds, -- template - nt.id AS template_id, + nt.id AS template_id, nt.title_template, - nt.body_template + nt.body_template, + -- preferences + (CASE WHEN np.disabled IS NULL THEN false ELSE np.disabled END)::bool AS disabled FROM acquired nm - JOIN notification_templates nt ON nm.notification_template_id = nt.id; + JOIN notification_templates nt ON nm.notification_template_id = nt.id + LEFT JOIN notification_preferences AS np + ON (np.user_id = nm.user_id AND np.notification_template_id = nm.notification_template_id); -- name: BulkMarkNotificationMessagesFailed :execrows UPDATE notification_messages @@ -131,4 +135,37 @@ WHERE id IN WHERE nested.updated_at < NOW() - INTERVAL '7 days'); -- name: GetNotificationMessagesByStatus :many -SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int; +SELECT * +FROM notification_messages +WHERE status = @status +LIMIT sqlc.arg('limit')::int; + +-- name: GetUserNotificationPreferences :many +SELECT * +FROM notification_preferences +WHERE user_id = @user_id::uuid; + +-- name: UpdateUserNotificationPreferences :execrows +INSERT +INTO notification_preferences (user_id, notification_template_id, disabled) +SELECT @user_id::uuid, new_values.notification_template_id, new_values.disabled +FROM (SELECT UNNEST(@notification_template_ids::uuid[]) AS notification_template_id, + UNNEST(@disableds::bool[]) AS disabled) AS new_values +ON CONFLICT (user_id, notification_template_id) DO UPDATE + SET disabled = EXCLUDED.disabled, + updated_at = CURRENT_TIMESTAMP; + +-- name: UpdateNotificationTemplateMethodByID :one +UPDATE notification_templates +SET method = sqlc.narg('method')::notification_method +WHERE id = @id::uuid +RETURNING *; + +-- name: GetNotificationTemplateByID :one +SELECT * +FROM notification_templates +WHERE id = @id::uuid; + +-- name: GetNotificationTemplatesByKind :many +SELECT * FROM notification_templates +WHERE kind = @kind::notification_template_kind; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index aecae02d572ff..f6d37d9bf67a3 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -24,6 +24,7 @@ const ( UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); + UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); @@ -59,6 +60,7 @@ const ( UniqueTemplateVersionsPkey UniqueConstraint = "template_versions_pkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_pkey PRIMARY KEY (id); UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name); UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); + UniqueUniqueUserNotificationTemplate UniqueConstraint = "unique_user_notification_template" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT unique_user_notification_template UNIQUE (user_id, notification_template_id); UniqueUserLinksPkey UniqueConstraint = "user_links_pkey" // ALTER TABLE ONLY user_links ADD CONSTRAINT user_links_pkey PRIMARY KEY (user_id, login_type); UniqueUsersPkey UniqueConstraint = "users_pkey" // ALTER TABLE ONLY users ADD CONSTRAINT users_pkey PRIMARY KEY (id); UniqueWorkspaceAgentLogSourcesPkey UniqueConstraint = "workspace_agent_log_sources_pkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_pkey PRIMARY KEY (workspace_agent_id, id); diff --git a/coderd/httpmw/notificationtemplateparam.go b/coderd/httpmw/notificationtemplateparam.go new file mode 100644 index 0000000000000..ae86d48d033e8 --- /dev/null +++ b/coderd/httpmw/notificationtemplateparam.go @@ -0,0 +1,49 @@ +package httpmw + +import ( + "context" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +type notificationTemplateParamContextKey struct{} + +// NotificationTemplateParam returns the template from the ExtractNotificationTemplateParam handler. +func NotificationTemplateParam(r *http.Request) database.NotificationTemplate { + template, ok := r.Context().Value(notificationTemplateParamContextKey{}).(database.NotificationTemplate) + if !ok { + panic("developer error: notification template param middleware not provided") + } + return template +} + +// ExtractNotificationTemplateParam grabs a notification template from the "notification_template" URL parameter. +func ExtractNotificationTemplateParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + notifTemplateID, parsed := ParseUUIDParam(rw, r, "notification_template") + if !parsed { + return + } + nt, err := db.GetNotificationTemplateById(r.Context(), notifTemplateID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching notification template.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, notificationTemplateParamContextKey{}, nt) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/notifications.go b/coderd/notifications.go index f6bcbe0c7183d..0545f19ec2026 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -11,7 +11,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" ) @@ -19,7 +18,7 @@ import ( // @ID get-notifications-settings // @Security CoderSessionToken // @Produce json -// @Tags General +// @Tags Notifications // @Success 200 {object} codersdk.NotificationsSettings // @Router /notifications/settings [get] func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { @@ -51,7 +50,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Accept json // @Produce json -// @Tags General +// @Tags Notifications // @Param request body codersdk.NotificationsSettings true "Notifications settings request" // @Success 200 {object} codersdk.NotificationsSettings // @Success 304 @@ -59,13 +58,6 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ - Message: "Insufficient permissions to update notifications settings.", - }) - return - } - var settings codersdk.NotificationsSettings if !httpapi.Read(ctx, rw, r, &settings) { return @@ -80,9 +72,9 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request return } - currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + currentSettingsJSON, err := api.Database.GetNotificationsSettings(ctx) if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to fetch current notifications settings.", Detail: err.Error(), }) @@ -91,7 +83,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { // See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1 - httpapi.Write(r.Context(), rw, http.StatusNotModified, nil) + httpapi.Write(ctx, rw, http.StatusNotModified, nil) return } @@ -110,13 +102,58 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request } err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON)) + if err != nil { + if rbac.IsUnauthorizedError(err) { + httpapi.Forbidden(rw) + } else { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications settings.", + Detail: err.Error(), + }) + } + + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} + +// @Summary Get system notification templates +// @ID get-system-notification-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Notifications +// @Success 200 {array} codersdk.NotificationTemplate +// @Router /notifications/templates/system [get] +func (api *API) systemNotificationTemplates(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + templates, err := api.Database.GetNotificationTemplatesByKind(ctx, database.NotificationTemplateKindSystem) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update notifications settings.", + Message: "Failed to retrieve system notifications templates.", Detail: err.Error(), }) return } - httpapi.Write(r.Context(), rw, http.StatusOK, settings) + out := convertNotificationTemplates(templates) + httpapi.Write(r.Context(), rw, http.StatusOK, out) +} + +func convertNotificationTemplates(in []database.NotificationTemplate) (out []codersdk.NotificationTemplate) { + for _, tmpl := range in { + out = append(out, codersdk.NotificationTemplate{ + ID: tmpl.ID, + Name: tmpl.Name, + TitleTemplate: tmpl.TitleTemplate, + BodyTemplate: tmpl.BodyTemplate, + Actions: string(tmpl.Actions), + Group: tmpl.Group.String, + Method: string(tmpl.Method.NotificationMethod), + Kind: string(tmpl.Kind), + }) + } + + return out } diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 7690154a0db80..19bc3044f8d68 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -11,13 +11,23 @@ import ( "github.com/coder/coder/v2/testutil" ) +func createOpts(t *testing.T) *coderdtest.Options { + t.Helper() + + dt := coderdtest.DeploymentValues(t) + dt.Experiments = []string{string(codersdk.ExperimentNotifications)} + return &coderdtest.Options{ + DeploymentValues: dt, + } +} + func TestUpdateNotificationsSettings(t *testing.T) { t.Parallel() t.Run("Permissions denied", func(t *testing.T) { t.Parallel() - api := coderdtest.New(t, nil) + api := coderdtest.New(t, createOpts(t)) firstUser := coderdtest.CreateFirstUser(t, api) anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) @@ -41,7 +51,7 @@ func TestUpdateNotificationsSettings(t *testing.T) { t.Run("Settings modified", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + client := coderdtest.New(t, createOpts(t)) _ = coderdtest.CreateFirstUser(t, client) // given @@ -65,7 +75,7 @@ func TestUpdateNotificationsSettings(t *testing.T) { t.Parallel() // Empty state: notifications Settings are undefined now (default). - client := coderdtest.New(t, nil) + client := coderdtest.New(t, createOpts(t)) _ = coderdtest.CreateFirstUser(t, client) ctx := testutil.Context(t, testutil.WaitShort) diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index bc2846da49564..7645f65c5c502 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -102,6 +102,22 @@ var ( Type: "license", } + // ResourceNotificationPreference + // Valid Actions + // - "ActionRead" :: read notification preferences + // - "ActionUpdate" :: update notification preferences + ResourceNotificationPreference = Object{ + Type: "notification_preference", + } + + // ResourceNotificationTemplate + // Valid Actions + // - "ActionRead" :: read notification templates + // - "ActionUpdate" :: update notification templates + ResourceNotificationTemplate = Object{ + Type: "notification_template", + } + // ResourceOauth2App // Valid Actions // - "ActionCreate" :: make an OAuth2 app. @@ -272,6 +288,8 @@ func AllResources() []Objecter { ResourceFile, ResourceGroup, ResourceLicense, + ResourceNotificationPreference, + ResourceNotificationTemplate, ResourceOauth2App, ResourceOauth2AppCodeToken, ResourceOauth2AppSecret, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 2390c9e30c785..54dcbe358007b 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -255,4 +255,16 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionDelete: actDef(""), }, }, + "notification_template": { + Actions: map[Action]ActionDefinition{ + ActionRead: actDef("read notification templates"), + ActionUpdate: actDef("update notification templates"), + }, + }, + "notification_preference": { + Actions: map[Action]ActionDefinition{ + ActionRead: actDef("read notification preferences"), + ActionUpdate: actDef("update notification preferences"), + }, + }, } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 225e5eb9d311e..b52fc0beb9c6b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -590,6 +590,54 @@ func TestRolePermissions(t *testing.T) { false: {}, }, }, + { + // Any owner/admin across may access any users' preferences + // Members may not access other members' preferences + Name: "NotificationPreferencesOwn", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceNotificationPreference.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, orgMemberMe, owner}, + false: { + userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, + { + // Any owner/admin may access notification templates + Name: "NotificationTemplates", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceNotificationTemplate, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner}, + false: { + memberMe, orgMemberMe, userAdmin, orgUserAdmin, templateAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + orgAdmin, otherOrgAdmin, + }, + }, + }, + { + // Notification preferences are currently not organization-scoped + // Any owner/admin may access any users' preferences + // Members may not access other members' preferences + Name: "NotificationPreferencesOtherUser", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceNotificationPreference.InOrg(orgID).WithOwner(uuid.NewString()), // some other user + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {orgAdmin, owner}, + false: { + memberMe, templateAdmin, orgUserAdmin, userAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + otherOrgAdmin, orgMemberMe, + }, + }, + }, // AnyOrganization tests { Name: "CreateOrgMember", @@ -630,6 +678,37 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + // Notification preferences are currently not organization-scoped + // Any owner/admin across any organization may access any users' preferences + // Members may access their own preferences + Name: "NotificationPreferencesAnyOrg", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceNotificationPreference.AnyOrganization().WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {orgMemberMe, orgAdmin, otherOrgAdmin, owner}, + false: { + memberMe, templateAdmin, otherOrgUserAdmin, userAdmin, orgUserAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgTemplateAdmin, + }, + }, + }, + { + // Notification templates are currently not organization-scoped + // Any owner/admin across any organization may access notification templates + Name: "NotificationTemplateAnyOrg", + Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, + Resource: rbac.ResourceNotificationPreference.AnyOrganization(), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {orgAdmin, otherOrgAdmin, owner}, + false: { + orgMemberMe, memberMe, templateAdmin, orgUserAdmin, userAdmin, + orgAuditor, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/audit.go b/codersdk/audit.go index 33b4714f03df6..7d83c8e238ce0 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -33,6 +33,7 @@ const ( ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" ResourceTypeOrganizationMember = "organization_member" + ResourceTypeNotificationTemplate = "notification_template" ) func (r ResourceType) FriendlyString() string { @@ -75,6 +76,8 @@ func (r ResourceType) FriendlyString() string { return "custom role" case ResourceTypeOrganizationMember: return "organization member" + case ResourceTypeNotificationTemplate: + return "notification template" default: return "unknown" } diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 58829eed57891..0e690615497d0 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -3,13 +3,31 @@ package codersdk import ( "context" "encoding/json" + "fmt" + "io" "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" ) type NotificationsSettings struct { NotifierPaused bool `json:"notifier_paused"` } +type NotificationTemplate struct { + ID uuid.UUID `json:"id" format:"uuid"` + Name string `json:"name"` + TitleTemplate string `json:"title_template"` + BodyTemplate string `json:"body_template"` + Actions string `json:"actions" format:""` + Group string `json:"group"` + Method string `json:"method"` + Kind string `json:"kind"` +} + +// GetNotificationsSettings retrieves the notifications settings, which currently just describes whether all +// notifications are paused from sending. func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil) if err != nil { @@ -23,6 +41,8 @@ func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSet return settings, json.NewDecoder(res.Body).Decode(&settings) } +// PutNotificationsSettings modifies the notifications settings, which currently just controls whether all +// notifications are paused from sending. func (c *Client) PutNotificationsSettings(ctx context.Context, settings NotificationsSettings) error { res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/settings", settings) if err != nil { @@ -38,3 +58,53 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica } return nil } + +// UpdateNotificationTemplateMethod modifies a notification template to use a specific notification method, overriding +// the method set in the deployment configuration. +func (c *Client) UpdateNotificationTemplateMethod(ctx context.Context, notificationTemplateId uuid.UUID, method string) error { + res, err := c.Request(ctx, http.MethodPut, + fmt.Sprintf("/api/v2/notifications/templates/%s/method", notificationTemplateId), + UpdateNotificationTemplateMethod{Method: method}, + ) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotModified { + return nil + } + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + +// GetSystemNotificationTemplates retrieves all notification templates pertaining to internal system events. +func (c *Client) GetSystemNotificationTemplates(ctx context.Context) ([]NotificationTemplate, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/templates/system", nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var templates []NotificationTemplate + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, xerrors.Errorf("read response body: %w", err) + } + + if err := json.Unmarshal(body, &templates); err != nil { + return nil, xerrors.Errorf("unmarshal response body: %w", err) + } + + return templates, nil +} + +type UpdateNotificationTemplateMethod struct { + Method string `json:"method,omitempty" example:"webhook"` +} diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 573fea66b8c80..788cab912643b 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -4,32 +4,34 @@ package codersdk type RBACResource string const ( - ResourceWildcard RBACResource = "*" - ResourceApiKey RBACResource = "api_key" - ResourceAssignOrgRole RBACResource = "assign_org_role" - ResourceAssignRole RBACResource = "assign_role" - ResourceAuditLog RBACResource = "audit_log" - ResourceDebugInfo RBACResource = "debug_info" - ResourceDeploymentConfig RBACResource = "deployment_config" - ResourceDeploymentStats RBACResource = "deployment_stats" - ResourceFile RBACResource = "file" - ResourceGroup RBACResource = "group" - ResourceLicense RBACResource = "license" - ResourceOauth2App RBACResource = "oauth2_app" - ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token" - ResourceOauth2AppSecret RBACResource = "oauth2_app_secret" - ResourceOrganization RBACResource = "organization" - ResourceOrganizationMember RBACResource = "organization_member" - ResourceProvisionerDaemon RBACResource = "provisioner_daemon" - ResourceProvisionerKeys RBACResource = "provisioner_keys" - ResourceReplicas RBACResource = "replicas" - ResourceSystem RBACResource = "system" - ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" - ResourceTemplate RBACResource = "template" - ResourceUser RBACResource = "user" - ResourceWorkspace RBACResource = "workspace" - ResourceWorkspaceDormant RBACResource = "workspace_dormant" - ResourceWorkspaceProxy RBACResource = "workspace_proxy" + ResourceWildcard RBACResource = "*" + ResourceApiKey RBACResource = "api_key" + ResourceAssignOrgRole RBACResource = "assign_org_role" + ResourceAssignRole RBACResource = "assign_role" + ResourceAuditLog RBACResource = "audit_log" + ResourceDebugInfo RBACResource = "debug_info" + ResourceDeploymentConfig RBACResource = "deployment_config" + ResourceDeploymentStats RBACResource = "deployment_stats" + ResourceFile RBACResource = "file" + ResourceGroup RBACResource = "group" + ResourceLicense RBACResource = "license" + ResourceNotificationPreference RBACResource = "notification_preference" + ResourceNotificationTemplate RBACResource = "notification_template" + ResourceOauth2App RBACResource = "oauth2_app" + ResourceOauth2AppCodeToken RBACResource = "oauth2_app_code_token" + ResourceOauth2AppSecret RBACResource = "oauth2_app_secret" + ResourceOrganization RBACResource = "organization" + ResourceOrganizationMember RBACResource = "organization_member" + ResourceProvisionerDaemon RBACResource = "provisioner_daemon" + ResourceProvisionerKeys RBACResource = "provisioner_keys" + ResourceReplicas RBACResource = "replicas" + ResourceSystem RBACResource = "system" + ResourceTailnetCoordinator RBACResource = "tailnet_coordinator" + ResourceTemplate RBACResource = "template" + ResourceUser RBACResource = "user" + ResourceWorkspace RBACResource = "workspace" + ResourceWorkspaceDormant RBACResource = "workspace_dormant" + ResourceWorkspaceProxy RBACResource = "workspace_proxy" ) type RBACAction string @@ -53,30 +55,32 @@ const ( // RBACResourceActions is the mapping of resources to which actions are valid for // said resource type. var RBACResourceActions = map[RBACResource][]RBACAction{ - ResourceWildcard: {}, - ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, - ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, - ResourceAuditLog: {ActionCreate, ActionRead}, - ResourceDebugInfo: {ActionRead}, - ResourceDeploymentConfig: {ActionRead, ActionUpdate}, - ResourceDeploymentStats: {ActionRead}, - ResourceFile: {ActionCreate, ActionRead}, - ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead}, - ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead}, - ResourceReplicas: {ActionRead}, - ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, - ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, - ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, - ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, - ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceWildcard: {}, + ResourceApiKey: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, + ResourceAssignRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead}, + ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceDebugInfo: {ActionRead}, + ResourceDeploymentConfig: {ActionRead, ActionUpdate}, + ResourceDeploymentStats: {ActionRead}, + ResourceFile: {ActionCreate, ActionRead}, + ResourceGroup: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceLicense: {ActionCreate, ActionDelete, ActionRead}, + ResourceNotificationPreference: {ActionRead, ActionUpdate}, + ResourceNotificationTemplate: {ActionRead, ActionUpdate}, + ResourceOauth2App: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOauth2AppCodeToken: {ActionCreate, ActionDelete, ActionRead}, + ResourceOauth2AppSecret: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganization: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceOrganizationMember: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerDaemon: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceProvisionerKeys: {ActionCreate, ActionDelete, ActionRead}, + ResourceReplicas: {ActionRead}, + ResourceSystem: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTailnetCoordinator: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, + ResourceTemplate: {ActionCreate, ActionDelete, ActionRead, ActionUpdate, ActionViewInsights}, + ResourceUser: {ActionCreate, ActionDelete, ActionRead, ActionReadPersonal, ActionUpdate, ActionUpdatePersonal}, + ResourceWorkspace: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceDormant: {ActionApplicationConnect, ActionCreate, ActionDelete, ActionRead, ActionSSH, ActionWorkspaceStart, ActionWorkspaceStop, ActionUpdate}, + ResourceWorkspaceProxy: {ActionCreate, ActionDelete, ActionRead, ActionUpdate}, } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 87f07cf243125..7b5375fff94f3 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -18,6 +18,7 @@ We track the following resources: | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationTemplate
|
FieldTracked
actionstrue
body_templatetrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| | NotificationsSettings
|
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
|
FieldTracked
callback_urltrue
created_atfalse
icontrue
idfalse
nametrue
updated_atfalse
| | OAuth2ProviderAppSecret
|
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index dec875eebaac3..a4de96701c817 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -537,6 +537,26 @@ curl -X DELETE http://coder-server:8080/api/v2/licenses/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Update notification template dispatch method + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/templates/{notification_template}/method \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/templates/{notification_template}/method` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get OAuth2 applications. ### Code samples diff --git a/docs/api/general.md b/docs/api/general.md index e913a4c804cd6..52cfd25f4c46c 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -667,84 +667,6 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get notifications settings - -### Code samples - -```shell -# Example request using curl -curl -X GET http://coder-server:8080/api/v2/notifications/settings \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`GET /notifications/settings` - -### Example responses - -> 200 Response - -```json -{ - "notifier_paused": true -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - -## Update notifications settings - -### Code samples - -```shell -# Example request using curl -curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' \ - -H 'Coder-Session-Token: API_KEY' -``` - -`PUT /notifications/settings` - -> Body parameter - -```json -{ - "notifier_paused": true -} -``` - -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ | -| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request | - -### Example responses - -> 200 Response - -```json -{ - "notifier_paused": true -} -``` - -### Responses - -| Status | Meaning | Description | Schema | -| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | -| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | - -To perform this operation, you must be authenticated. [Learn more](authentication.md). - ## Update check ### Code samples diff --git a/docs/api/members.md b/docs/api/members.md index 1ecf490738f00..63bf06b9b0f8c 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -164,47 +164,49 @@ Status Code **200** #### Enumerated Values -| Property | Value | -| --------------- | ----------------------- | -| `action` | `application_connect` | -| `action` | `assign` | -| `action` | `create` | -| `action` | `delete` | -| `action` | `read` | -| `action` | `read_personal` | -| `action` | `ssh` | -| `action` | `update` | -| `action` | `update_personal` | -| `action` | `use` | -| `action` | `view_insights` | -| `action` | `start` | -| `action` | `stop` | -| `resource_type` | `*` | -| `resource_type` | `api_key` | -| `resource_type` | `assign_org_role` | -| `resource_type` | `assign_role` | -| `resource_type` | `audit_log` | -| `resource_type` | `debug_info` | -| `resource_type` | `deployment_config` | -| `resource_type` | `deployment_stats` | -| `resource_type` | `file` | -| `resource_type` | `group` | -| `resource_type` | `license` | -| `resource_type` | `oauth2_app` | -| `resource_type` | `oauth2_app_code_token` | -| `resource_type` | `oauth2_app_secret` | -| `resource_type` | `organization` | -| `resource_type` | `organization_member` | -| `resource_type` | `provisioner_daemon` | -| `resource_type` | `provisioner_keys` | -| `resource_type` | `replicas` | -| `resource_type` | `system` | -| `resource_type` | `tailnet_coordinator` | -| `resource_type` | `template` | -| `resource_type` | `user` | -| `resource_type` | `workspace` | -| `resource_type` | `workspace_dormant` | -| `resource_type` | `workspace_proxy` | +| Property | Value | +| --------------- | ------------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `notification_preference` | +| `resource_type` | `notification_template` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_keys` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -287,47 +289,49 @@ Status Code **200** #### Enumerated Values -| Property | Value | -| --------------- | ----------------------- | -| `action` | `application_connect` | -| `action` | `assign` | -| `action` | `create` | -| `action` | `delete` | -| `action` | `read` | -| `action` | `read_personal` | -| `action` | `ssh` | -| `action` | `update` | -| `action` | `update_personal` | -| `action` | `use` | -| `action` | `view_insights` | -| `action` | `start` | -| `action` | `stop` | -| `resource_type` | `*` | -| `resource_type` | `api_key` | -| `resource_type` | `assign_org_role` | -| `resource_type` | `assign_role` | -| `resource_type` | `audit_log` | -| `resource_type` | `debug_info` | -| `resource_type` | `deployment_config` | -| `resource_type` | `deployment_stats` | -| `resource_type` | `file` | -| `resource_type` | `group` | -| `resource_type` | `license` | -| `resource_type` | `oauth2_app` | -| `resource_type` | `oauth2_app_code_token` | -| `resource_type` | `oauth2_app_secret` | -| `resource_type` | `organization` | -| `resource_type` | `organization_member` | -| `resource_type` | `provisioner_daemon` | -| `resource_type` | `provisioner_keys` | -| `resource_type` | `replicas` | -| `resource_type` | `system` | -| `resource_type` | `tailnet_coordinator` | -| `resource_type` | `template` | -| `resource_type` | `user` | -| `resource_type` | `workspace` | -| `resource_type` | `workspace_dormant` | -| `resource_type` | `workspace_proxy` | +| Property | Value | +| --------------- | ------------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `notification_preference` | +| `resource_type` | `notification_template` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_keys` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -541,46 +545,48 @@ Status Code **200** #### Enumerated Values -| Property | Value | -| --------------- | ----------------------- | -| `action` | `application_connect` | -| `action` | `assign` | -| `action` | `create` | -| `action` | `delete` | -| `action` | `read` | -| `action` | `read_personal` | -| `action` | `ssh` | -| `action` | `update` | -| `action` | `update_personal` | -| `action` | `use` | -| `action` | `view_insights` | -| `action` | `start` | -| `action` | `stop` | -| `resource_type` | `*` | -| `resource_type` | `api_key` | -| `resource_type` | `assign_org_role` | -| `resource_type` | `assign_role` | -| `resource_type` | `audit_log` | -| `resource_type` | `debug_info` | -| `resource_type` | `deployment_config` | -| `resource_type` | `deployment_stats` | -| `resource_type` | `file` | -| `resource_type` | `group` | -| `resource_type` | `license` | -| `resource_type` | `oauth2_app` | -| `resource_type` | `oauth2_app_code_token` | -| `resource_type` | `oauth2_app_secret` | -| `resource_type` | `organization` | -| `resource_type` | `organization_member` | -| `resource_type` | `provisioner_daemon` | -| `resource_type` | `provisioner_keys` | -| `resource_type` | `replicas` | -| `resource_type` | `system` | -| `resource_type` | `tailnet_coordinator` | -| `resource_type` | `template` | -| `resource_type` | `user` | -| `resource_type` | `workspace` | -| `resource_type` | `workspace_dormant` | -| `resource_type` | `workspace_proxy` | +| Property | Value | +| --------------- | ------------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `notification_preference` | +| `resource_type` | `notification_template` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `provisioner_keys` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/notifications.md b/docs/api/notifications.md new file mode 100644 index 0000000000000..ca43565a982a9 --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,135 @@ +# Notifications + +## Get notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/settings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/settings` + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/settings` + +> Body parameter + +```json +{ + "notifier_paused": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ | +| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request | + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | +| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get system notification templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/templates/system \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/templates/system` + +### Example responses + +> 200 Response + +```json +[ + { + "actions": "string", + "body_template": "string", + "group": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "kind": "string", + "method": "string", + "name": "string", + "title_template": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationTemplate](schemas.md#codersdknotificationtemplate) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» actions` | string | false | | | +| `» body_template` | string | false | | | +| `» group` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» kind` | string | false | | | +| `» method` | string | false | | | +| `» name` | string | false | | | +| `» title_template` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c1ec9979a0a13..b50ce7b6b230d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3136,6 +3136,34 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `id` | string | true | | | | `username` | string | true | | | +## codersdk.NotificationTemplate + +```json +{ + "actions": "string", + "body_template": "string", + "group": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "kind": "string", + "method": "string", + "name": "string", + "title_template": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ------ | -------- | ------------ | ----------- | +| `actions` | string | false | | | +| `body_template` | string | false | | | +| `group` | string | false | | | +| `id` | string | false | | | +| `kind` | string | false | | | +| `method` | string | false | | | +| `name` | string | false | | | +| `title_template` | string | false | | | + ## codersdk.NotificationsConfig ```json @@ -4148,34 +4176,36 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o #### Enumerated Values -| Value | -| ----------------------- | -| `*` | -| `api_key` | -| `assign_org_role` | -| `assign_role` | -| `audit_log` | -| `debug_info` | -| `deployment_config` | -| `deployment_stats` | -| `file` | -| `group` | -| `license` | -| `oauth2_app` | -| `oauth2_app_code_token` | -| `oauth2_app_secret` | -| `organization` | -| `organization_member` | -| `provisioner_daemon` | -| `provisioner_keys` | -| `replicas` | -| `system` | -| `tailnet_coordinator` | -| `template` | -| `user` | -| `workspace` | -| `workspace_dormant` | -| `workspace_proxy` | +| Value | +| ------------------------- | +| `*` | +| `api_key` | +| `assign_org_role` | +| `assign_role` | +| `audit_log` | +| `debug_info` | +| `deployment_config` | +| `deployment_stats` | +| `file` | +| `group` | +| `license` | +| `notification_preference` | +| `notification_template` | +| `oauth2_app` | +| `oauth2_app_code_token` | +| `oauth2_app_secret` | +| `organization` | +| `organization_member` | +| `provisioner_daemon` | +| `provisioner_keys` | +| `replicas` | +| `system` | +| `tailnet_coordinator` | +| `template` | +| `user` | +| `workspace` | +| `workspace_dormant` | +| `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/manifest.json b/docs/manifest.json index 82dd73ada47c8..4b686ed9598b6 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -601,6 +601,10 @@ "title": "Members", "path": "./api/members.md" }, + { + "title": "Notifications", + "path": "./api/notifications.md" + }, { "title": "Organizations", "path": "./api/organizations.md" diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go index 007f475f6f5eb..07cd8a5fdcb87 100644 --- a/enterprise/audit/diff.go +++ b/enterprise/audit/diff.go @@ -142,6 +142,13 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) { } return leftInt64Ptr, rightInt64Ptr, true + case database.NullNotificationMethod: + vl, vr := string(typedLeft.NotificationMethod), "" + if val, ok := right.(database.NullNotificationMethod); ok { + vr = string(val.NotificationMethod) + } + + return vl, vr, true case database.TemplateACL: return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true case database.CustomRolePermissions: diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 0a3608dae7169..8474bc8a6cd16 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -271,6 +271,16 @@ var auditableResourcesTypes = map[any]map[string]Action{ "display_name": ActionTrack, "icon": ActionTrack, }, + &database.NotificationTemplate{}: { + "id": ActionIgnore, + "name": ActionTrack, + "title_template": ActionTrack, + "body_template": ActionTrack, + "actions": ActionTrack, + "group": ActionTrack, + "method": ActionTrack, + "kind": ActionTrack, + }, } // auditMap converts a map of struct pointers to a map of struct names as diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index e9e8d7d196af0..5fbd1569d0207 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -368,7 +368,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) - r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( api.autostopRequirementEnabledMW, @@ -388,6 +387,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Post("/jfrog/xray-scan", api.postJFrogXrayScan) r.Get("/jfrog/xray-scan", api.jFrogXrayScan) }) + + // The /notifications base route is mounted by the AGPL router, so we can't group it here. + // Additionally, because we have a static route for /notifications/templates/system which conflicts + // with the below route, we need to register this route without any mounts or groups to make both work. + r.With( + apiKeyMiddleware, + httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications), + httpmw.ExtractNotificationTemplateParam(options.Database), + ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/notifications.go b/enterprise/coderd/notifications.go new file mode 100644 index 0000000000000..bd51ac3d803c1 --- /dev/null +++ b/enterprise/coderd/notifications.go @@ -0,0 +1,97 @@ +package coderd + +import ( + "fmt" + "net/http" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Update notification template dispatch method +// @ID post-notification-template-method +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Success 200 +// @Router /notifications/templates/{notification_template}/method [put] +func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) { + // TODO: authorization (restrict to admin/template admin?) + var ( + ctx = r.Context() + template = httpmw.NotificationTemplateParam(r) + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.NotificationTemplate](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + + var req codersdk.UpdateNotificationTemplateMethod + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + var nm database.NullNotificationMethod + if err := nm.Scan(req.Method); err != nil || !nm.Valid || !nm.NotificationMethod.Valid() { + vals := database.AllNotificationMethodValues() + acceptable := make([]string, len(vals)) + for i, v := range vals { + acceptable[i] = string(v) + } + + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update notification template method", + Validations: []codersdk.ValidationError{ + { + Field: "method", + Detail: fmt.Sprintf("%q is not a valid method; %s are the available options", + req.Method, strings.Join(acceptable, ", "), + ), + }, + }, + }) + return + } + + if template.Method == nm { + httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ + Message: "Notification template method unchanged.", + }) + return + } + + defer commitAudit() + aReq.Old = template + + err := api.Database.InTx(func(tx database.Store) error { + var err error + template, err = api.Database.UpdateNotificationTemplateMethodById(r.Context(), database.UpdateNotificationTemplateMethodByIdParams{ + ID: template.ID, + Method: nm, + }) + if err != nil { + return xerrors.Errorf("failed to update notification template ID: %w", err) + } + + return err + }, nil) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + aReq.New = template + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Successfully updated notification template method.", + }) +} diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go new file mode 100644 index 0000000000000..7ada95292b8f1 --- /dev/null +++ b/enterprise/coderd/notifications_test.go @@ -0,0 +1,180 @@ +package coderd_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/testutil" +) + +func createOpts(t *testing.T, usePostgres bool) *coderdenttest.Options { + t.Helper() + + var ( + db database.Store + ps pubsub.Pubsub + ) + + if usePostgres { + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table") + } + + db, ps = dbtestutil.NewDB(t) + } else { + db, ps = dbmem.New(), pubsub.NewInMemory() + } + + dt := coderdtest.DeploymentValues(t) + dt.Experiments = []string{string(codersdk.ExperimentNotifications)} + return &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dt, + Database: db, + Pubsub: ps, + }, + } +} + +func TestUpdateNotificationTemplateMethod(t *testing.T) { + t.Parallel() + + t.Run("Happy path", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + api, _ := coderdenttest.New(t, createOpts(t, true)) + + var ( + method = string(database.NotificationMethodSmtp) + templateID = notifications.TemplateWorkspaceDeleted + ) + + // Given: a template whose method is initially empty (i.e. deferring to the global method value). + template, err := getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Empty(t, template.Method) + + // When: calling the API to update the method. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed") + + // Then: the method should be set. + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + }) + + t.Run("Insufficient permissions", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + // Given: the first user which has an "owner" role, and another user which does not. + api, firstUser := coderdenttest.New(t, createOpts(t, false)) + anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // When: calling the API as an unprivileged user. + err := anotherClient.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, string(database.NotificationMethodWebhook)) + + // Then: the request is denied because of insufficient permissions. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusNotFound, sdkError.StatusCode()) + require.Equal(t, "Resource not found or you do not have access to this resource", sdkError.Response.Message) + }) + + t.Run("Invalid notification method", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + + // Given: the first user which has an "owner" role + api, _ := coderdenttest.New(t, createOpts(t, true)) + + // When: calling the API with an invalid method. + const method = "nope" + err := api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method) + + // Then: the request is invalid because of the unacceptable method. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusBadRequest, sdkError.StatusCode()) + require.Equal(t, "Invalid request to update notification template method", sdkError.Response.Message) + require.Len(t, sdkError.Response.Validations, 1) + require.Equal(t, "method", sdkError.Response.Validations[0].Field) + require.Equal(t, fmt.Sprintf("%q is not a valid method; smtp, webhook are the available options", method), sdkError.Response.Validations[0].Detail) + }) + + t.Run("Not modified", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitSuperLong) + api, _ := coderdenttest.New(t, createOpts(t, true)) + + var ( + method = string(database.NotificationMethodSmtp) + templateID = notifications.TemplateWorkspaceDeleted + ) + + template, err := getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + + // Given: a template whose method is initially empty (i.e. deferring to the global method value). + require.Empty(t, template.Method) + + // When: calling the API to update the method, it should set it. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "initial request to set the method failed") + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + + // Then: when calling the API again with the same method, the method will remain unchanged. + require.NoError(t, api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateWorkspaceDeleted, method), "second request to set the method failed") + template, err = getTemplateById(t, ctx, api, templateID) + require.NoError(t, err) + require.NotNil(t, template) + require.Equal(t, method, template.Method) + }) +} + +func getTemplateById(t *testing.T, ctx context.Context, api *codersdk.Client, id uuid.UUID) (*codersdk.NotificationTemplate, error) { + t.Helper() + + var template *codersdk.NotificationTemplate + templates, err := api.GetSystemNotificationTemplates(ctx) + if err != nil { + return nil, err + } + + for _, tmpl := range templates { + if tmpl.ID == id { + template = &tmpl + } + } + + if template == nil { + return nil, xerrors.Errorf("template not found: %q", id.String()) + } + + return template, nil +} diff --git a/site/src/api/rbacresources_gen.ts b/site/src/api/rbacresources_gen.ts index 37fe508fde89c..6cee389dfbc7a 100644 --- a/site/src/api/rbacresources_gen.ts +++ b/site/src/api/rbacresources_gen.ts @@ -55,6 +55,14 @@ export const RBACResourceActions: Partial< delete: "delete license", read: "read licenses", }, + notification_preference: { + read: "read notification preferences", + update: "update notification preferences", + }, + notification_template: { + read: "read notification templates", + update: "update notification templates", + }, oauth2_app: { create: "make an OAuth2 app.", delete: "delete an OAuth2 app", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0ad30e1310cff..daa16a50454d4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -708,6 +708,18 @@ export interface MinimalUser { readonly avatar_url: string; } +// From codersdk/notifications.go +export interface NotificationTemplate { + readonly id: string; + readonly name: string; + readonly title_template: string; + readonly body_template: string; + readonly actions: string; + readonly group: string; + readonly method: string; + readonly kind: string; +} + // From codersdk/deployment.go export interface NotificationsConfig { readonly max_send_attempts: number; @@ -1437,6 +1449,11 @@ export interface UpdateCheckResponse { readonly url: string; } +// From codersdk/notifications.go +export interface UpdateNotificationTemplateMethod { + readonly method?: string; +} + // From codersdk/organizations.go export interface UpdateOrganizationRequest { readonly name?: string; @@ -2259,6 +2276,8 @@ export type RBACResource = | "file" | "group" | "license" + | "notification_preference" + | "notification_template" | "oauth2_app" | "oauth2_app_code_token" | "oauth2_app_secret" @@ -2286,6 +2305,8 @@ export const RBACResources: RBACResource[] = [ "file", "group", "license", + "notification_preference", + "notification_template", "oauth2_app", "oauth2_app_code_token", "oauth2_app_secret",