diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 538d67b81fc2d..6554372157207 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1547,6 +1547,71 @@ const docTemplate = `{ } } }, + "/notifications/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get notifications settings", + "operationId": "get-notifications-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Update notifications settings", + "operationId": "update-notifications-settings", + "parameters": [ + { + "description": "Notifications settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + }, + "304": { + "description": "Not Modified" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -10009,6 +10074,14 @@ const docTemplate = `{ } } }, + "codersdk.NotificationsSettings": { + "type": "object", + "properties": { + "notifier_paused": { + "type": "boolean" + } + } + }, "codersdk.NotificationsWebhookConfig": { "type": "object", "properties": { @@ -11036,6 +11109,7 @@ const docTemplate = `{ "license", "convert_login", "health_settings", + "notifications_settings", "workspace_proxy", "organization", "oauth2_provider_app", @@ -11054,6 +11128,7 @@ const docTemplate = `{ "ResourceTypeLicense", "ResourceTypeConvertLogin", "ResourceTypeHealthSettings", + "ResourceTypeNotificationsSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 49dfde7a6b651..03b0ba7716e2b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1344,6 +1344,61 @@ } } }, + "/notifications/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get notifications settings", + "operationId": "get-notifications-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Update notifications settings", + "operationId": "update-notifications-settings", + "parameters": [ + { + "description": "Notifications settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + }, + "304": { + "description": "Not Modified" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -8978,6 +9033,14 @@ } } }, + "codersdk.NotificationsSettings": { + "type": "object", + "properties": { + "notifier_paused": { + "type": "boolean" + } + } + }, "codersdk.NotificationsWebhookConfig": { "type": "object", "properties": { @@ -9958,6 +10021,7 @@ "license", "convert_login", "health_settings", + "notifications_settings", "workspace_proxy", "organization", "oauth2_provider_app", @@ -9976,6 +10040,7 @@ "ResourceTypeLicense", "ResourceTypeConvertLogin", "ResourceTypeHealthSettings", + "ResourceTypeNotificationsSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 09ae80c9ddf90..129b904c75b03 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -20,6 +20,7 @@ type Auditable interface { database.WorkspaceProxy | database.AuditOAuthConvertState | database.HealthSettings | + database.NotificationsSettings | database.OAuth2ProviderApp | database.OAuth2ProviderAppSecret | database.CustomRole | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 1c027fc85527f..403bb13ccf3f8 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -99,6 +99,8 @@ func ResourceTarget[T Auditable](tgt T) string { return string(typed.ToLoginType) case database.HealthSettings: return "" // no target? + case database.NotificationsSettings: + return "" // no target? case database.OAuth2ProviderApp: return typed.Name case database.OAuth2ProviderAppSecret: @@ -142,6 +144,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.HealthSettings: // Artificial ID for auditing purposes return typed.ID + case database.NotificationsSettings: + // Artificial ID for auditing purposes + return typed.ID case database.OAuth2ProviderApp: return typed.ID case database.OAuth2ProviderAppSecret: @@ -183,6 +188,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeConvertLogin case database.HealthSettings: return database.ResourceTypeHealthSettings + case database.NotificationsSettings: + return database.ResourceTypeNotificationsSettings case database.OAuth2ProviderApp: return database.ResourceTypeOauth2ProviderApp case database.OAuth2ProviderAppSecret: @@ -225,6 +232,9 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.HealthSettings: // Artificial ID for auditing purposes return false + case database.NotificationsSettings: + // Artificial ID for auditing purposes + return false case database.OAuth2ProviderApp: return false case database.OAuth2ProviderAppSecret: diff --git a/coderd/coderd.go b/coderd/coderd.go index 3e77490651e01..0a3414fdb984c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1243,6 +1243,11 @@ func New(options *Options) *API { }) }) }) + r.Route("/notifications", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/settings", api.notificationsSettings) + r.Put("/settings", api.putNotificationsSettings) + }) }) if options.SwaggerEndpoint { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 67dadd5d74e19..1feea0c23bbe7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1479,6 +1479,11 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } +func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) { + // No authz checks + return q.db.GetNotificationsSettings(ctx) +} + func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -3687,6 +3692,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error { return q.db.UpsertLogoURL(ctx, value) } +func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertNotificationsSettings(ctx, value) +} + func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e615fd3054d6e..52d375116e6a3 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2350,6 +2350,12 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("UpsertHealthSettings", s.Subtest(func(db database.Store, check *expects) { check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + check.Args().Asserts() + })) + s.Run("UpsertNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("GetDeploymentWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { check.Args(time.Time{}).Asserts() })) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3954e47f43846..420d6bc466420 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -199,6 +199,7 @@ type data struct { lastUpdateCheck []byte announcementBanners []byte healthSettings []byte + notificationsSettings []byte applicationName string logoURL string appSecurityKey string @@ -2771,6 +2772,17 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } +func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.notificationsSettings == nil { + return "{}", nil + } + + return string(q.notificationsSettings), nil +} + func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -8668,8 +8680,8 @@ func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertD } func (q *FakeQuerier) UpsertHealthSettings(_ context.Context, data string) error { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() q.healthSettings = []byte(data) return nil @@ -8717,13 +8729,21 @@ func (q *FakeQuerier) UpsertLastUpdateCheck(_ context.Context, data string) erro } func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() q.logoURL = data return nil } +func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.notificationsSettings = []byte(data) + return nil +} + func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 0a7ecd4fb5f10..638aeaac14746 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -739,6 +739,13 @@ func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg d return r0, r1 } +func (m metricsStore) GetNotificationsSettings(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationsSettings(ctx) + m.queryLatencies.WithLabelValues("GetNotificationsSettings").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id) @@ -2300,6 +2307,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error { return r0 } +func (m metricsStore) UpsertNotificationsSettings(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.UpsertNotificationsSettings(ctx, value) + m.queryLatencies.WithLabelValues("UpsertNotificationsSettings").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertOAuthSigningKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 982a6472ec16c..5fc5403a64f7f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1467,6 +1467,21 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1) } +// GetNotificationsSettings mocks base method. +func (m *MockStore) GetNotificationsSettings(arg0 context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationsSettings", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationsSettings indicates an expected call of GetNotificationsSettings. +func (mr *MockStoreMockRecorder) GetNotificationsSettings(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationsSettings", reflect.TypeOf((*MockStore)(nil).GetNotificationsSettings), arg0) +} + // GetOAuth2ProviderAppByID mocks base method. func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -4813,6 +4828,20 @@ func (mr *MockStoreMockRecorder) UpsertLogoURL(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertLogoURL", reflect.TypeOf((*MockStore)(nil).UpsertLogoURL), arg0, arg1) } +// UpsertNotificationsSettings mocks base method. +func (m *MockStore) UpsertNotificationsSettings(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertNotificationsSettings", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertNotificationsSettings indicates an expected call of UpsertNotificationsSettings. +func (mr *MockStoreMockRecorder) UpsertNotificationsSettings(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationsSettings", reflect.TypeOf((*MockStore)(nil).UpsertNotificationsSettings), arg0, arg1) +} + // UpsertOAuthSigningKey mocks base method. func (m *MockStore) UpsertOAuthSigningKey(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f0b9cb311606f..3f2da45155da1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -163,7 +163,8 @@ CREATE TYPE resource_type AS ENUM ( 'oauth2_provider_app', 'oauth2_provider_app_secret', 'custom_role', - 'organization_member' + 'organization_member', + 'notifications_settings' ); CREATE TYPE startup_script_behavior AS ENUM ( diff --git a/coderd/database/migrations/000223_notifications_settings_audit.down.sql b/coderd/database/migrations/000223_notifications_settings_audit.down.sql new file mode 100644 index 0000000000000..de5e2cb77a38d --- /dev/null +++ b/coderd/database/migrations/000223_notifications_settings_audit.down.sql @@ -0,0 +1,2 @@ +-- Nothing to do +-- It's not possible to drop enum values from enum types, so the up migration has "IF NOT EXISTS". diff --git a/coderd/database/migrations/000223_notifications_settings_audit.up.sql b/coderd/database/migrations/000223_notifications_settings_audit.up.sql new file mode 100644 index 0000000000000..09afa99193166 --- /dev/null +++ b/coderd/database/migrations/000223_notifications_settings_audit.up.sql @@ -0,0 +1,2 @@ +-- This has to be outside a transaction +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'notifications_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7f34d7680abf2..4ff84ddc8891f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1352,6 +1352,7 @@ const ( ResourceTypeOauth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" ResourceTypeOrganizationMember ResourceType = "organization_member" + ResourceTypeNotificationsSettings ResourceType = "notifications_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1407,7 +1408,8 @@ func (e ResourceType) Valid() bool { ResourceTypeOauth2ProviderApp, ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, - ResourceTypeOrganizationMember: + ResourceTypeOrganizationMember, + ResourceTypeNotificationsSettings: return true } return false @@ -1432,6 +1434,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, ResourceTypeOrganizationMember, + ResourceTypeNotificationsSettings, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 75ade1dc12e5e..c4ce70cea28fe 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -161,6 +161,7 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, 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) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) @@ -454,6 +455,7 @@ type sqlcQuerier interface { UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error UpsertLastUpdateCheck(ctx context.Context, value string) error UpsertLogoURL(ctx context.Context, value string) error + UpsertNotificationsSettings(ctx context.Context, value string) error UpsertOAuthSigningKey(ctx context.Context, value string) error UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 95f25ee1dbd11..83be6184706ce 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6319,6 +6319,18 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) { return value, err } +const getNotificationsSettings = `-- name: GetNotificationsSettings :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings +` + +func (q *sqlQuerier) GetNotificationsSettings(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getNotificationsSettings) + var notifications_settings string + err := row.Scan(¬ifications_settings) + return notifications_settings, err +} + const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one SELECT value FROM site_configs WHERE key = 'oauth_signing_key' ` @@ -6431,6 +6443,16 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error { return err } +const upsertNotificationsSettings = `-- name: UpsertNotificationsSettings :exec +INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings' +` + +func (q *sqlQuerier) UpsertNotificationsSettings(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertNotificationsSettings, value) + return err +} + const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1) ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 2b56a6d1455af..9287a4aee0b54 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -79,3 +79,13 @@ SELECT -- name: UpsertHealthSettings :exec INSERT INTO site_configs (key, value) VALUES ('health_settings', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'health_settings'; + +-- name: GetNotificationsSettings :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings +; + +-- name: UpsertNotificationsSettings :exec +INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings'; + diff --git a/coderd/database/types.go b/coderd/database/types.go index fd7a2fed82300..7113b09e14a70 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -30,6 +30,11 @@ type HealthSettings struct { DismissedHealthchecks []healthsdk.HealthSection `db:"dismissed_healthchecks" json:"dismissed_healthchecks"` } +type NotificationsSettings struct { + ID uuid.UUID `db:"id" json:"id"` + NotifierPaused bool `db:"notifier_paused" json:"notifier_paused"` +} + type Actions []policy.Action func (a *Actions) Scan(src interface{}) error { diff --git a/coderd/notifications.go b/coderd/notifications.go new file mode 100644 index 0000000000000..f6bcbe0c7183d --- /dev/null +++ b/coderd/notifications.go @@ -0,0 +1,122 @@ +package coderd + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "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/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get notifications settings +// @ID get-notifications-settings +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.NotificationsSettings +// @Router /notifications/settings [get] +func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { + settingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch current notifications settings.", + Detail: err.Error(), + }) + return + } + + var settings codersdk.NotificationsSettings + if len(settingsJSON) > 0 { + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal notifications settings.", + Detail: err.Error(), + }) + return + } + } + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} + +// @Summary Update notifications settings +// @ID update-notifications-settings +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags General +// @Param request body codersdk.NotificationsSettings true "Notifications settings request" +// @Success 200 {object} codersdk.NotificationsSettings +// @Success 304 +// @Router /notifications/settings [put] +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 + } + + settingsJSON, err := json.Marshal(&settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal notifications settings.", + Detail: err.Error(), + }) + return + } + + currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch current notifications settings.", + Detail: err.Error(), + }) + return + } + + 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) + return + } + + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.NotificationsSettings](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + + aReq.New = database.NotificationsSettings{ + ID: uuid.New(), + NotifierPaused: settings.NotifierPaused, + } + + err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON)) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications settings.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index ee07913c5dc87..fe161cc2cd8f6 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -12,13 +12,14 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/dispatch" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/testutil" - "github.com/coder/serpent" ) func TestBufferedUpdates(t *testing.T) { diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a8cdf4c96e8a9..c38daa1531ecb 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -538,6 +538,71 @@ func TestInvalidConfig(t *testing.T) { require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout) } +func TestNotifierPaused(t *testing.T) { + t.Parallel() + + // setup + ctx, logger, db := setupInMemory(t) + + // Prepare the test + handler := &fakeHandler{} + method := database.NotificationMethodSmtp + user := createSampleUser(t, db) + + cfg := defaultNotificationsConfig(method) + fetchInterval := time.Nanosecond // Let + cfg.FetchInterval = *serpent.DurationOf(&fetchInterval) + mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + + mgr.Run(ctx) + + // Notifier is on, enqueue the first message. + sid, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + require.Eventually(t, func() bool { + handler.mu.RLock() + defer handler.mu.RUnlock() + return slices.Contains(handler.succeeded, sid.String()) + }, testutil.WaitShort, testutil.IntervalFast) + + // Pause the notifier. + settingsJSON, err := json.Marshal(&codersdk.NotificationsSettings{NotifierPaused: true}) + require.NoError(t, err) + err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) + require.NoError(t, err) + + // Notifier is paused, enqueue the next message. + sid, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + require.Eventually(t, func() bool { + pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusPending, + }) + assert.NoError(t, err) + return len(pendingMessages) == 1 + }, testutil.WaitShort, testutil.IntervalFast) + + // Unpause the notifier. + settingsJSON, err = json.Marshal(&codersdk.NotificationsSettings{NotifierPaused: false}) + require.NoError(t, err) + err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) + require.NoError(t, err) + + // Notifier is running again, message should be dequeued. + require.Eventually(t, func() bool { + handler.mu.RLock() + defer handler.mu.RUnlock() + return slices.Contains(handler.succeeded, sid.String()) + }, testutil.WaitShort, testutil.IntervalFast) +} + type fakeHandler struct { mu sync.RWMutex @@ -546,7 +611,7 @@ type fakeHandler struct { } func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { - return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + return func(_ context.Context, msgID uuid.UUID) (retryable bool, err error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index b214f8a77a070..d400b52166b78 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -71,10 +71,18 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu default: } - // Call process() immediately (i.e. don't wait an initial tick). - err := n.process(ctx, success, failure) + // Check if notifier is not paused. + ok, err := n.ensureRunning(ctx) if err != nil { - n.log.Error(ctx, "failed to process messages", slog.Error(err)) + n.log.Warn(ctx, "failed to check notifier state", slog.Error(err)) + } + + if ok { + // Call process() immediately (i.e. don't wait an initial tick). + err = n.process(ctx, success, failure) + if err != nil { + n.log.Error(ctx, "failed to process messages", slog.Error(err)) + } } // Shortcut to bail out quickly if stop() has been called or the context canceled. @@ -89,6 +97,31 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu } } +// ensureRunning checks if notifier is not paused. +func (n *notifier) ensureRunning(ctx context.Context) (bool, error) { + n.log.Debug(ctx, "check if notifier is paused") + + settingsJSON, err := n.store.GetNotificationsSettings(ctx) + if err != nil { + return false, xerrors.Errorf("get notifications settings: %w", err) + } + + var settings codersdk.NotificationsSettings + if len(settingsJSON) == 0 { + return true, nil // settings.NotifierPaused is false by default + } + + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + return false, xerrors.Errorf("unmarshal notifications settings") + } + + if settings.NotifierPaused { + n.log.Debug(ctx, "notifier is paused, notifications will not be delivered") + } + return !settings.NotifierPaused, nil +} + // process is responsible for coordinating the retrieval, processing, and delivery of messages. // Messages are dispatched concurrently, but they may block when success/failure channels are full. // diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go index 63f6af7101d1b..bba0d4e183c5c 100644 --- a/coderd/notifications/spec.go +++ b/coderd/notifications/spec.go @@ -21,6 +21,7 @@ type Store interface { EnqueueNotificationMessage(ctx context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) + GetNotificationsSettings(ctx context.Context) (string, error) } // Handler is responsible for preparing and delivering a notification by a given method. diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go new file mode 100644 index 0000000000000..7690154a0db80 --- /dev/null +++ b/coderd/notifications_test.go @@ -0,0 +1,95 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestUpdateNotificationsSettings(t *testing.T) { + t.Parallel() + + t.Run("Permissions denied", func(t *testing.T) { + t.Parallel() + + api := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, api) + anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: true, + } + + ctx := testutil.Context(t, testutil.WaitShort) + + // when + err := anotherClient.PutNotificationsSettings(ctx, expected) + + // then + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) + }) + + t.Run("Settings modified", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: true, + } + + ctx := testutil.Context(t, testutil.WaitShort) + + // when + err := client.PutNotificationsSettings(ctx, expected) + require.NoError(t, err) + + // then + actual, err := client.GetNotificationsSettings(ctx) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("Settings not modified", func(t *testing.T) { + t.Parallel() + + // Empty state: notifications Settings are undefined now (default). + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitShort) + + // Change the state: pause notifications + err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + NotifierPaused: true, + }) + require.NoError(t, err) + + // Verify the state: notifications are paused. + actual, err := client.GetNotificationsSettings(ctx) + require.NoError(t, err) + require.True(t, actual.NotifierPaused) + + // Change the stage again: notifications are paused. + expected := actual + err = client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + NotifierPaused: true, + }) + require.NoError(t, err) + + // Verify the state: notifications are still paused, and there is no error returned. + actual, err = client.GetNotificationsSettings(ctx) + require.NoError(t, err) + require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) + }) +} diff --git a/codersdk/audit.go b/codersdk/audit.go index 683db5406c13f..75bfe6204c607 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -14,20 +14,21 @@ import ( type ResourceType string const ( - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeGitSSHKey ResourceType = "git_ssh_key" - ResourceTypeAPIKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeLicense ResourceType = "license" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeOrganization ResourceType = "organization" - ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeGitSSHKey ResourceType = "git_ssh_key" + ResourceTypeAPIKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeLicense ResourceType = "license" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeNotificationsSettings ResourceType = "notifications_settings" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeOrganization ResourceType = "organization" + ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app" // nolint:gosec // This is not a secret. ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" @@ -64,6 +65,8 @@ func (r ResourceType) FriendlyString() string { return "organization" case ResourceTypeHealthSettings: return "health_settings" + case ResourceTypeNotificationsSettings: + return "notifications_settings" case ResourceTypeOAuth2ProviderApp: return "oauth2 app" case ResourceTypeOAuth2ProviderAppSecret: diff --git a/codersdk/notifications.go b/codersdk/notifications.go new file mode 100644 index 0000000000000..58829eed57891 --- /dev/null +++ b/codersdk/notifications.go @@ -0,0 +1,40 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" +) + +type NotificationsSettings struct { + NotifierPaused bool `json:"notifier_paused"` +} + +func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil) + if err != nil { + return NotificationsSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return NotificationsSettings{}, ReadBodyAsError(res) + } + var settings NotificationsSettings + return settings, json.NewDecoder(res.Body).Decode(&settings) +} + +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 { + return err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotModified { + return nil + } + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 5f34e6bf475c4..f239589f9700a 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
| +| 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
| | Organization
|
FieldTracked
created_atfalse
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| diff --git a/docs/api/general.md b/docs/api/general.md index 8bd968c6b18ed..c628604b92123 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -651,6 +651,84 @@ 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/schemas.md b/docs/api/schemas.md index 5e2eaf7b74784..fd0d4c87437d4 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3122,6 +3122,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `hello` | string | false | | The hostname identifying the SMTP server. | | `smarthost` | [serpent.HostPort](#serpenthostport) | false | | The intermediary SMTP host through which emails are sent (host:port). | +## codersdk.NotificationsSettings + +```json +{ + "notifier_paused": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | ------- | -------- | ------------ | ----------- | +| `notifier_paused` | boolean | false | | | + ## codersdk.NotificationsWebhookConfig ```json @@ -4157,6 +4171,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `license` | | `convert_login` | | `health_settings` | +| `notifications_settings` | | `workspace_proxy` | | `organization` | | `oauth2_provider_app` | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ed52b5e921560..f2ec06701904d 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -213,6 +213,10 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "dismissed_healthchecks": ActionTrack, }, + &database.NotificationsSettings{}: { + "id": ActionIgnore, + "notifier_paused": ActionTrack, + }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 &database.License{}: { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ad142b41392d0..6a3ce9adaae82 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -709,6 +709,11 @@ export interface NotificationsEmailConfig { readonly hello: string; } +// From codersdk/notifications.go +export interface NotificationsSettings { + readonly notifier_paused: boolean; +} + // From codersdk/deployment.go export interface NotificationsWebhookConfig { readonly endpoint: string; @@ -2242,6 +2247,7 @@ export type ResourceType = | "group" | "health_settings" | "license" + | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" @@ -2259,6 +2265,7 @@ export const ResourceTypes: ResourceType[] = [ "group", "health_settings", "license", + "notifications_settings", "oauth2_provider_app", "oauth2_provider_app_secret", "organization",