feat: add notification preferences database & audit support (#14100)

This commit is contained in:
Danny Kopping
2024-08-05 16:18:45 +02:00
committed by GitHub
parent 49a2880abc
commit e164b1e71c
49 changed files with 3229 additions and 368 deletions
+13 -3
View File
@@ -16,6 +16,16 @@ 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 TestNotifications(t *testing.T) {
t.Parallel()
@@ -42,7 +52,7 @@ func TestNotifications(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
_ = coderdtest.CreateFirstUser(t, ownerClient)
// when
@@ -72,7 +82,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) {
t.Parallel()
// given
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
owner := coderdtest.CreateFirstUser(t, ownerClient)
anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
@@ -87,7 +97,7 @@ func TestPauseNotifications_RegularUser(t *testing.T) {
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
assert.Contains(t, sdkError.Message, "Insufficient permissions to update notifications settings.")
assert.Contains(t, sdkError.Message, "Forbidden.")
// then
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
+251 -2
View File
@@ -1547,6 +1547,34 @@ const docTemplate = `{
}
}
},
"/notifications/dispatch-methods": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Notifications"
],
"summary": "Get notification dispatch methods",
"operationId": "get-notification-dispatch-methods",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationMethodsResponse"
}
}
}
}
}
},
"/notifications/settings": {
"get": {
"security": [
@@ -1558,7 +1586,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"General"
"Notifications"
],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
@@ -1584,7 +1612,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"General"
"Notifications"
],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
@@ -1612,6 +1640,68 @@ 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": "update-notification-template-dispatch-method",
"parameters": [
{
"type": "string",
"description": "Notification template UUID",
"name": "notification_template",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Success"
},
"304": {
"description": "Not modified"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -5354,6 +5444,90 @@ const docTemplate = `{
}
}
},
"/users/{user}/notifications/preferences": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Notifications"
],
"summary": "Get user notification preferences",
"operationId": "get-user-notification-preferences",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationPreference"
}
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Notifications"
],
"summary": "Update user notification preferences",
"operationId": "update-user-notification-preferences",
"parameters": [
{
"description": "Preferences",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences"
}
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationPreference"
}
}
}
}
}
},
"/users/{user}/organizations": {
"get": {
"security": [
@@ -10202,6 +10376,66 @@ const docTemplate = `{
}
}
},
"codersdk.NotificationMethodsResponse": {
"type": "object",
"properties": {
"available": {
"type": "array",
"items": {
"type": "string"
}
},
"default": {
"type": "string"
}
}
},
"codersdk.NotificationPreference": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"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": {
@@ -11217,6 +11451,8 @@ const docTemplate = `{
"file",
"group",
"license",
"notification_preference",
"notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -11245,6 +11481,8 @@ const docTemplate = `{
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
"ResourceNotificationPreference",
"ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
@@ -12513,6 +12751,17 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateUserNotificationPreferences": {
"type": "object",
"properties": {
"template_disabled_map": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"codersdk.UpdateUserPasswordRequest": {
"type": "object",
"required": [
+229 -2
View File
@@ -1344,6 +1344,30 @@
}
}
},
"/notifications/dispatch-methods": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Notifications"],
"summary": "Get notification dispatch methods",
"operationId": "get-notification-dispatch-methods",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationMethodsResponse"
}
}
}
}
}
},
"/notifications/settings": {
"get": {
"security": [
@@ -1352,7 +1376,7 @@
}
],
"produces": ["application/json"],
"tags": ["General"],
"tags": ["Notifications"],
"summary": "Get notifications settings",
"operationId": "get-notifications-settings",
"responses": {
@@ -1372,7 +1396,7 @@
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["General"],
"tags": ["Notifications"],
"summary": "Update notifications settings",
"operationId": "update-notifications-settings",
"parameters": [
@@ -1399,6 +1423,60 @@
}
}
},
"/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": "update-notification-template-dispatch-method",
"parameters": [
{
"type": "string",
"description": "Notification template UUID",
"name": "notification_template",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Success"
},
"304": {
"description": "Not modified"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -4726,6 +4804,80 @@
}
}
},
"/users/{user}/notifications/preferences": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Notifications"],
"summary": "Get user notification preferences",
"operationId": "get-user-notification-preferences",
"parameters": [
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationPreference"
}
}
}
}
},
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Notifications"],
"summary": "Update user notification preferences",
"operationId": "update-user-notification-preferences",
"parameters": [
{
"description": "Preferences",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateUserNotificationPreferences"
}
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.NotificationPreference"
}
}
}
}
}
},
"/users/{user}/organizations": {
"get": {
"security": [
@@ -9143,6 +9295,66 @@
}
}
},
"codersdk.NotificationMethodsResponse": {
"type": "object",
"properties": {
"available": {
"type": "array",
"items": {
"type": "string"
}
},
"default": {
"type": "string"
}
}
},
"codersdk.NotificationPreference": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
},
"id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"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": {
@@ -10119,6 +10331,8 @@
"file",
"group",
"license",
"notification_preference",
"notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",
@@ -10147,6 +10361,8 @@
"ResourceFile",
"ResourceGroup",
"ResourceLicense",
"ResourceNotificationPreference",
"ResourceNotificationTemplate",
"ResourceOauth2App",
"ResourceOauth2AppCodeToken",
"ResourceOauth2AppSecret",
@@ -11362,6 +11578,17 @@
}
}
},
"codersdk.UpdateUserNotificationPreferences": {
"type": "object",
"properties": {
"template_disabled_map": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"codersdk.UpdateUserPasswordRequest": {
"type": "object",
"required": ["password"],
+2 -1
View File
@@ -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
+9
View File
@@ -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))
}
+14 -1
View File
@@ -1050,6 +1050,12 @@ func New(options *Options) *API {
})
r.Get("/gitsshkey", api.gitSSHKey)
r.Put("/gitsshkey", api.regenerateGitSSHKey)
r.Route("/notifications", func(r chi.Router) {
r.Route("/preferences", func(r chi.Router) {
r.Get("/", api.userNotificationPreferences)
r.Put("/", api.putUserNotificationPreferences)
})
})
})
})
})
@@ -1243,9 +1249,16 @@ 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)
})
r.Get("/dispatch-methods", api.notificationDispatchMethods)
})
})
+38
View File
@@ -1474,6 +1474,23 @@ 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?
// All notification templates share the same rbac.Object, so there is no need
// to authorize them individually. If this passes, all notification templates can be read.
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 +2102,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 +3035,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
@@ -3326,6 +3357,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 {
+39
View File
@@ -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"
@@ -2561,6 +2562,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)
@@ -2596,6 +2601,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() {
+87
View File
@@ -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,18 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat
return out, nil
}
func (*FakeQuerier) GetNotificationTemplateByID(_ context.Context, _ uuid.UUID) (database.NotificationTemplate, error) {
// Not implementing this function because it relies on state in the database which is created with migrations.
// We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
return database.NotificationTemplate{}, ErrUnimplemented
}
func (*FakeQuerier) GetNotificationTemplatesByKind(_ context.Context, _ database.NotificationTemplateKind) ([]database.NotificationTemplate, error) {
// Not implementing this function because it relies on state in the database which is created with migrations.
// We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
return nil, ErrUnimplemented
}
func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -4853,6 +4867,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 +7550,12 @@ func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMe
return database.OrganizationMember{}, sql.ErrNoRows
}
func (*FakeQuerier) UpdateNotificationTemplateMethodByID(_ context.Context, _ database.UpdateNotificationTemplateMethodByIDParams) (database.NotificationTemplate, error) {
// Not implementing this function because it relies on state in the database which is created with migrations.
// We could consider using code-generation to align the database state and dbmem, but it's not worth it right now.
return database.NotificationTemplate{}, ErrUnimplemented
}
func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg database.UpdateOAuth2ProviderAppByIDParams) (database.OAuth2ProviderApp, error) {
err := validateDatabaseType(arg)
if err != nil {
@@ -8114,6 +8150,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 0, 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 = dbtime.Now()
q.notificationPreferences[j] = np
upserted++
found = true
break
}
if !found {
np := database.NotificationPreference{
Disabled: disabled,
UserID: arg.UserID,
NotificationTemplateID: templateID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.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
+35
View File
@@ -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)
@@ -2139,6 +2167,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)
+75
View File
@@ -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()
@@ -4504,6 +4564,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()
+49 -3
View File
@@ -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,
@@ -1536,6 +1571,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);
@@ -1798,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();
@@ -1851,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;
@@ -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;
@@ -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;
@@ -0,0 +1,52 @@
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)
);
-- 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';
@@ -0,0 +1,5 @@
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');
+74 -2
View File
@@ -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.
+5
View File
@@ -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)
@@ -427,6 +431,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)
+172 -12
View File
@@ -3335,14 +3335,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 {
@@ -3361,6 +3365,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.
@@ -3396,6 +3401,7 @@ func (q *sqlQuerier) AcquireNotificationMessages(ctx context.Context, arg Acquir
&i.TemplateID,
&i.TitleTemplate,
&i.BodyTemplate,
&i.Disabled,
); err != nil {
return nil, err
}
@@ -3534,10 +3540,11 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue
const fetchNewMessageMetadata = `-- name: FetchNewMessageMetadata :one
SELECT nt.name AS notification_name,
nt.actions AS actions,
nt.method AS custom_method,
u.id AS user_id,
u.email AS user_email,
COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name,
COALESCE(u.username, '') AS user_username
u.username AS user_username
FROM notification_templates nt,
users u
WHERE nt.id = $1
@@ -3550,12 +3557,13 @@ type FetchNewMessageMetadataParams struct {
}
type FetchNewMessageMetadataRow struct {
NotificationName string `db:"notification_name" json:"notification_name"`
Actions []byte `db:"actions" json:"actions"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
UserName string `db:"user_name" json:"user_name"`
UserUsername string `db:"user_username" json:"user_username"`
NotificationName string `db:"notification_name" json:"notification_name"`
Actions []byte `db:"actions" json:"actions"`
CustomMethod NullNotificationMethod `db:"custom_method" json:"custom_method"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
UserName string `db:"user_name" json:"user_name"`
UserUsername string `db:"user_username" json:"user_username"`
}
// This is used to build up the notification_message's JSON payload.
@@ -3565,6 +3573,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
err := row.Scan(
&i.NotificationName,
&i.Actions,
&i.CustomMethod,
&i.UserID,
&i.UserEmail,
&i.UserName,
@@ -3574,7 +3583,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 {
@@ -3621,6 +3633,154 @@ 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
`
+46 -7
View File
@@ -2,10 +2,11 @@
-- This is used to build up the notification_message's JSON payload.
SELECT nt.name AS notification_name,
nt.actions AS actions,
nt.method AS custom_method,
u.id AS user_id,
u.email AS user_email,
COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''))::text AS user_name,
COALESCE(u.username, '') AS user_username
u.username AS user_username
FROM notification_templates nt,
users u
WHERE nt.id = @notification_template_id
@@ -79,14 +80,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 +136,38 @@ 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;
+1
View File
@@ -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);
@@ -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 middleware not used")
}
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))
})
}
}
+190 -14
View File
@@ -7,11 +7,13 @@ import (
"github.com/google/uuid"
"cdr.dev/slog"
"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/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@@ -19,7 +21,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 +53,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 +61,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 +75,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 +86,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
}
@@ -111,12 +106,193 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request
err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON))
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
if rbac.IsUnauthorizedError(err) {
httpapi.Forbidden(rw)
return
}
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 retrieve system notifications templates.",
Detail: err.Error(),
})
return
}
out := convertNotificationTemplates(templates)
httpapi.Write(r.Context(), rw, http.StatusOK, out)
}
// @Summary Get notification dispatch methods
// @ID get-notification-dispatch-methods
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Success 200 {array} codersdk.NotificationMethodsResponse
// @Router /notifications/dispatch-methods [get]
func (api *API) notificationDispatchMethods(rw http.ResponseWriter, r *http.Request) {
var methods []string
for _, nm := range database.AllNotificationMethodValues() {
methods = append(methods, string(nm))
}
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.NotificationMethodsResponse{
AvailableNotificationMethods: methods,
DefaultNotificationMethod: api.DeploymentValues.Notifications.Method.Value(),
})
}
// @Summary Get user notification preferences
// @ID get-user-notification-preferences
// @Security CoderSessionToken
// @Produce json
// @Tags Notifications
// @Param user path string true "User ID, name, or me"
// @Success 200 {array} codersdk.NotificationPreference
// @Router /users/{user}/notifications/preferences [get]
func (api *API) userNotificationPreferences(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID))
)
prefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID)
if err != nil {
logger.Error(ctx, "failed to retrieve preferences", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve user notification preferences.",
Detail: err.Error(),
})
return
}
out := convertNotificationPreferences(prefs)
httpapi.Write(ctx, rw, http.StatusOK, out)
}
// @Summary Update user notification preferences
// @ID update-user-notification-preferences
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Notifications
// @Param request body codersdk.UpdateUserNotificationPreferences true "Preferences"
// @Param user path string true "User ID, name, or me"
// @Success 200 {array} codersdk.NotificationPreference
// @Router /users/{user}/notifications/preferences [put]
func (api *API) putUserNotificationPreferences(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
user = httpmw.UserParam(r)
logger = api.Logger.Named("notifications.preferences").With(slog.F("user_id", user.ID))
)
// Parse request.
var prefs codersdk.UpdateUserNotificationPreferences
if !httpapi.Read(ctx, rw, r, &prefs) {
return
}
// Build query params.
input := database.UpdateUserNotificationPreferencesParams{
UserID: user.ID,
NotificationTemplateIds: make([]uuid.UUID, 0, len(prefs.TemplateDisabledMap)),
Disableds: make([]bool, 0, len(prefs.TemplateDisabledMap)),
}
for tmplID, disabled := range prefs.TemplateDisabledMap {
id, err := uuid.Parse(tmplID)
if err != nil {
logger.Warn(ctx, "failed to parse notification template UUID", slog.F("input", tmplID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unable to parse notification template UUID.",
Detail: err.Error(),
})
return
}
input.NotificationTemplateIds = append(input.NotificationTemplateIds, id)
input.Disableds = append(input.Disableds, disabled)
}
// Update preferences with params.
updated, err := api.Database.UpdateUserNotificationPreferences(ctx, input)
if err != nil {
logger.Error(ctx, "failed to update preferences", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to update user notifications preferences.",
Detail: err.Error(),
})
return
}
// Preferences updated, now fetch all preferences belonging to this user.
logger.Info(ctx, "updated preferences", slog.F("count", updated))
userPrefs, err := api.Database.GetUserNotificationPreferences(ctx, user.ID)
if err != nil {
logger.Error(ctx, "failed to retrieve preferences", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to retrieve user notifications preferences.",
Detail: err.Error(),
})
return
}
out := convertNotificationPreferences(userPrefs)
httpapi.Write(ctx, 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
}
func convertNotificationPreferences(in []database.NotificationPreference) (out []codersdk.NotificationPreference) {
for _, pref := range in {
out = append(out, codersdk.NotificationPreference{
NotificationTemplateID: pref.NotificationTemplateID,
Disabled: pref.Disabled,
UpdatedAt: pref.UpdatedAt,
})
}
return out
}
+34 -19
View File
@@ -3,6 +3,7 @@ package notifications
import (
"context"
"encoding/json"
"strings"
"text/template"
"github.com/google/uuid"
@@ -16,14 +17,13 @@ import (
"github.com/coder/coder/v2/codersdk"
)
var ErrCannotEnqueueDisabledNotification = xerrors.New("user has disabled this notification")
type StoreEnqueuer struct {
store Store
log slog.Logger
// TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none.
// For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants
// Slack notifications, and Mary doesn't want any.
method database.NotificationMethod
defaultMethod database.NotificationMethod
// helpers holds a map of template funcs which are used when rendering templates. These need to be passed in because
// the template funcs will return values which are inappropriately encapsulated in this struct.
helpers template.FuncMap
@@ -37,17 +37,31 @@ func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers tem
}
return &StoreEnqueuer{
store: store,
log: log,
method: method,
helpers: helpers,
store: store,
log: log,
defaultMethod: method,
helpers: helpers,
}, nil
}
// Enqueue queues a notification message for later delivery.
// Messages will be dequeued by a notifier later and dispatched.
func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) {
payload, err := s.buildPayload(ctx, userID, templateID, labels)
metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{
UserID: userID,
NotificationTemplateID: templateID,
})
if err != nil {
s.log.Warn(ctx, "failed to fetch message metadata", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err))
return nil, xerrors.Errorf("new message metadata: %w", err)
}
dispatchMethod := s.defaultMethod
if metadata.CustomMethod.Valid {
dispatchMethod = metadata.CustomMethod.NotificationMethod
}
payload, err := s.buildPayload(metadata, labels)
if err != nil {
s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err))
return nil, xerrors.Errorf("enqueue notification (payload build): %w", err)
@@ -63,12 +77,21 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
ID: id,
UserID: userID,
NotificationTemplateID: templateID,
Method: s.method,
Method: dispatchMethod,
Payload: input,
Targets: targets,
CreatedBy: createdBy,
})
if err != nil {
// We have a trigger on the notification_messages table named `inhibit_enqueue_if_disabled` which prevents messages
// from being enqueued if the user has disabled them via notification_preferences. The trigger will fail the insertion
// with the message "cannot enqueue message: user has disabled this notification".
//
// This is more efficient than fetching the user's preferences for each enqueue, and centralizes the business logic.
if strings.Contains(err.Error(), ErrCannotEnqueueDisabledNotification.Error()) {
return nil, ErrCannotEnqueueDisabledNotification
}
s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err))
return nil, xerrors.Errorf("enqueue notification: %w", err)
}
@@ -80,15 +103,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI
// buildPayload creates the payload that the notification will for variable substitution and/or routing.
// The payload contains information about the recipient, the event that triggered the notification, and any subsequent
// actions which can be taken by the recipient.
func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) {
metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{
UserID: userID,
NotificationTemplateID: templateID,
})
if err != nil {
return nil, xerrors.Errorf("new message metadata: %w", err)
}
func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) {
payload := types.MessagePayload{
Version: "1.0",
+14 -4
View File
@@ -149,7 +149,7 @@ func (m *Manager) loop(ctx context.Context) error {
var eg errgroup.Group
// Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications.
m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.method, m.metrics)
m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.metrics)
eg.Go(func() error {
return m.notifier.run(ctx, m.success, m.failure)
})
@@ -249,15 +249,24 @@ func (m *Manager) syncUpdates(ctx context.Context) {
for i := 0; i < nFailure; i++ {
res := <-m.failure
status := database.NotificationMessageStatusPermanentFailure
if res.retryable {
var (
reason string
status database.NotificationMessageStatus
)
switch {
case res.retryable:
status = database.NotificationMessageStatusTemporaryFailure
case res.inhibited:
status = database.NotificationMessageStatusInhibited
reason = "disabled by user"
default:
status = database.NotificationMessageStatusPermanentFailure
}
failureParams.IDs = append(failureParams.IDs, res.msg)
failureParams.FailedAts = append(failureParams.FailedAts, res.ts)
failureParams.Statuses = append(failureParams.Statuses, status)
var reason string
if res.err != nil {
reason = res.err.Error()
}
@@ -367,4 +376,5 @@ type dispatchResult struct {
ts time.Time
err error
retryable bool
inhibited bool
}
+75
View File
@@ -339,6 +339,81 @@ func TestInflightDispatchesMetric(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
}
func TestCustomMethodMetricCollection(t *testing.T) {
t.Parallel()
// SETUP
if !dbtestutil.WillUsePostgres() {
// UpdateNotificationTemplateMethodByID only makes sense with a real database.
t.Skip("This test requires postgres; it relies on business-logic only implemented in the database")
}
ctx, logger, store := setup(t)
var (
reg = prometheus.NewRegistry()
metrics = notifications.NewMetrics(reg)
template = notifications.TemplateWorkspaceDeleted
anotherTemplate = notifications.TemplateWorkspaceDormant
)
const (
customMethod = database.NotificationMethodWebhook
defaultMethod = database.NotificationMethodSmtp
)
// GIVEN: a template whose notification method differs from the default.
out, err := store.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
ID: template,
Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
})
require.NoError(t, err)
require.Equal(t, customMethod, out.Method.NotificationMethod)
// WHEN: two notifications (each with different templates) are enqueued.
cfg := defaultNotificationsConfig(defaultMethod)
mgr, err := notifications.NewManager(cfg, store, metrics, logger.Named("manager"))
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx))
})
smtpHandler := &fakeHandler{}
webhookHandler := &fakeHandler{}
mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{
defaultMethod: smtpHandler,
customMethod: webhookHandler,
})
enq, err := notifications.NewStoreEnqueuer(cfg, store, defaultHelpers(), logger.Named("enqueuer"))
require.NoError(t, err)
user := createSampleUser(t, store)
_, err = enq.Enqueue(ctx, user.ID, template, map[string]string{"type": "success"}, "test")
require.NoError(t, err)
_, err = enq.Enqueue(ctx, user.ID, anotherTemplate, map[string]string{"type": "success"}, "test")
require.NoError(t, err)
mgr.Run(ctx)
// THEN: the fake handlers to "dispatch" the notifications.
require.Eventually(t, func() bool {
smtpHandler.mu.RLock()
webhookHandler.mu.RLock()
defer smtpHandler.mu.RUnlock()
defer webhookHandler.mu.RUnlock()
return len(smtpHandler.succeeded) == 1 && len(smtpHandler.failed) == 0 &&
len(webhookHandler.succeeded) == 1 && len(webhookHandler.failed) == 0
}, testutil.WaitShort, testutil.IntervalFast)
// THEN: we should have metric series for both the default and custom notification methods.
require.Eventually(t, func() bool {
return promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(defaultMethod), anotherTemplate.String(), notifications.ResultSuccess)) > 0 &&
promtest.ToFloat64(metrics.DispatchAttempts.WithLabelValues(string(customMethod), template.String(), notifications.ResultSuccess)) > 0
}, testutil.WaitShort, testutil.IntervalFast)
}
// hasMatchingFingerprint checks if the given metric's series fingerprint matches the reference fingerprint.
func hasMatchingFingerprint(metric *dto.Metric, fp model.Fingerprint) bool {
return fingerprintLabelPairs(metric.Label) == fp
+189 -1
View File
@@ -604,7 +604,7 @@ func TestNotifierPaused(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
}
func TestNotifcationTemplatesBody(t *testing.T) {
func TestNotificationTemplatesBody(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
@@ -705,6 +705,194 @@ func TestNotifcationTemplatesBody(t *testing.T) {
}
}
// TestDisabledBeforeEnqueue ensures that notifications cannot be enqueued once a user has disabled that notification template
func TestDisabledBeforeEnqueue(t *testing.T) {
t.Parallel()
// SETUP
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it is testing business-logic implemented in the database")
}
ctx, logger, db := setup(t)
// GIVEN: an enqueuer & a sample user
cfg := defaultNotificationsConfig(database.NotificationMethodSmtp)
enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer"))
require.NoError(t, err)
user := createSampleUser(t, db)
// WHEN: the user has a preference set to not receive the "workspace deleted" notification
templateID := notifications.TemplateWorkspaceDeleted
n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{
UserID: user.ID,
NotificationTemplateIds: []uuid.UUID{templateID},
Disableds: []bool{true},
})
require.NoError(t, err, "failed to set preferences")
require.EqualValues(t, 1, n, "unexpected number of affected rows")
// THEN: enqueuing the "workspace deleted" notification should fail with an error
_, err = enq.Enqueue(ctx, user.ID, templateID, map[string]string{}, "test")
require.ErrorIs(t, err, notifications.ErrCannotEnqueueDisabledNotification, "enqueueing did not fail with expected error")
}
// TestDisabledAfterEnqueue ensures that notifications enqueued before a notification template was disabled will not be
// sent, and will instead be marked as "inhibited".
func TestDisabledAfterEnqueue(t *testing.T) {
t.Parallel()
// SETUP
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it is testing business-logic implemented in the database")
}
ctx, logger, db := setup(t)
method := database.NotificationMethodSmtp
cfg := defaultNotificationsConfig(method)
mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager"))
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, mgr.Stop(ctx))
})
enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer"))
require.NoError(t, err)
user := createSampleUser(t, db)
// GIVEN: a notification is enqueued which has not (yet) been disabled
templateID := notifications.TemplateWorkspaceDeleted
msgID, err := enq.Enqueue(ctx, user.ID, templateID, map[string]string{}, "test")
require.NoError(t, err)
// Disable the notification template.
n, err := db.UpdateUserNotificationPreferences(ctx, database.UpdateUserNotificationPreferencesParams{
UserID: user.ID,
NotificationTemplateIds: []uuid.UUID{templateID},
Disableds: []bool{true},
})
require.NoError(t, err, "failed to set preferences")
require.EqualValues(t, 1, n, "unexpected number of affected rows")
// WHEN: running the manager to trigger dequeueing of (now-disabled) messages
mgr.Run(ctx)
// THEN: the message should not be sent, and must be set to "inhibited"
require.EventuallyWithT(t, func(ct *assert.CollectT) {
m, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{
Status: database.NotificationMessageStatusInhibited,
Limit: 10,
})
assert.NoError(ct, err)
if assert.Equal(ct, len(m), 1) {
assert.Equal(ct, m[0].ID.String(), msgID.String())
assert.Contains(ct, m[0].StatusReason.String, "disabled by user")
}
}, testutil.WaitLong, testutil.IntervalFast, "did not find the expected inhibited message")
}
func TestCustomNotificationMethod(t *testing.T) {
t.Parallel()
// SETUP
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on business-logic only implemented in the database")
}
ctx, logger, db := setup(t)
received := make(chan uuid.UUID, 1)
// SETUP:
// Start mock server to simulate webhook endpoint.
mockWebhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload dispatch.WebhookPayload
err := json.NewDecoder(r.Body).Decode(&payload)
assert.NoError(t, err)
received <- payload.MsgID
close(received)
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte("noted."))
require.NoError(t, err)
}))
defer mockWebhookSrv.Close()
// Start mock SMTP server.
mockSMTPSrv := smtpmock.New(smtpmock.ConfigurationAttr{
LogToStdout: false,
LogServerActivity: true,
})
require.NoError(t, mockSMTPSrv.Start())
t.Cleanup(func() {
assert.NoError(t, mockSMTPSrv.Stop())
})
endpoint, err := url.Parse(mockWebhookSrv.URL)
require.NoError(t, err)
// GIVEN: a notification template which has a method explicitly set
var (
template = notifications.TemplateWorkspaceDormant
defaultMethod = database.NotificationMethodSmtp
customMethod = database.NotificationMethodWebhook
)
out, err := db.UpdateNotificationTemplateMethodByID(ctx, database.UpdateNotificationTemplateMethodByIDParams{
ID: template,
Method: database.NullNotificationMethod{NotificationMethod: customMethod, Valid: true},
})
require.NoError(t, err)
require.Equal(t, customMethod, out.Method.NotificationMethod)
// GIVEN: a manager configured with multiple dispatch methods
cfg := defaultNotificationsConfig(defaultMethod)
cfg.SMTP = codersdk.NotificationsEmailConfig{
From: "danny@coder.com",
Hello: "localhost",
Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())},
}
cfg.Webhook = codersdk.NotificationsWebhookConfig{
Endpoint: *serpent.URLOf(endpoint),
}
mgr, err := notifications.NewManager(cfg, db, createMetrics(), logger.Named("manager"))
require.NoError(t, err)
t.Cleanup(func() {
_ = mgr.Stop(ctx)
})
enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger)
require.NoError(t, err)
// WHEN: a notification of that template is enqueued, it should be delivered with the configured method - not the default.
user := createSampleUser(t, db)
msgID, err := enq.Enqueue(ctx, user.ID, template, map[string]string{}, "test")
require.NoError(t, err)
// THEN: the notification should be received by the custom dispatch method
mgr.Run(ctx)
receivedMsgID := testutil.RequireRecvCtx(ctx, t, received)
require.Equal(t, msgID.String(), receivedMsgID.String())
// Ensure no messages received by default method (SMTP):
msgs := mockSMTPSrv.MessagesAndPurge()
require.Len(t, msgs, 0)
// Enqueue a notification which does not have a custom method set to ensure default works correctly.
msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test")
require.NoError(t, err)
require.EventuallyWithT(t, func(ct *assert.CollectT) {
msgs := mockSMTPSrv.MessagesAndPurge()
if assert.Len(ct, msgs, 1) {
assert.Contains(ct, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID))
}
}, testutil.WaitLong, testutil.IntervalFast)
}
type fakeHandler struct {
mu sync.RWMutex
succeeded, failed []string
+27 -12
View File
@@ -10,6 +10,7 @@ import (
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
@@ -33,12 +34,11 @@ type notifier struct {
quit chan any
done chan any
method database.NotificationMethod
handlers map[database.NotificationMethod]Handler
metrics *Metrics
}
func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, method database.NotificationMethod, metrics *Metrics) *notifier {
func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler, metrics *Metrics) *notifier {
return &notifier{
id: id,
cfg: cfg,
@@ -48,7 +48,6 @@ func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger
tick: time.NewTicker(cfg.FetchInterval.Value()),
store: db,
handlers: hr,
method: method,
metrics: metrics,
}
}
@@ -144,6 +143,12 @@ func (n *notifier) process(ctx context.Context, success chan<- dispatchResult, f
var eg errgroup.Group
for _, msg := range msgs {
// If a notification template has been disabled by the user after a notification was enqueued, mark it as inhibited
if msg.Disabled {
failure <- n.newInhibitedDispatch(msg)
continue
}
// A message failing to be prepared correctly should not affect other messages.
deliverFn, err := n.prepare(ctx, msg)
if err != nil {
@@ -234,17 +239,17 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification
logger := n.log.With(slog.F("msg_id", msg.ID), slog.F("method", msg.Method), slog.F("attempt", msg.AttemptCount+1))
if msg.AttemptCount > 0 {
n.metrics.RetryCount.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc()
n.metrics.RetryCount.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc()
}
n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Inc()
n.metrics.QueuedSeconds.WithLabelValues(string(n.method)).Observe(msg.QueuedSeconds)
n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Inc()
n.metrics.QueuedSeconds.WithLabelValues(string(msg.Method)).Observe(msg.QueuedSeconds)
start := time.Now()
retryable, err := deliver(ctx, msg.ID)
n.metrics.DispatcherSendSeconds.WithLabelValues(string(n.method)).Observe(time.Since(start).Seconds())
n.metrics.InflightDispatches.WithLabelValues(string(n.method), msg.TemplateID.String()).Dec()
n.metrics.DispatcherSendSeconds.WithLabelValues(string(msg.Method)).Observe(time.Since(start).Seconds())
n.metrics.InflightDispatches.WithLabelValues(string(msg.Method), msg.TemplateID.String()).Dec()
if err != nil {
// Don't try to accumulate message responses if the context has been canceled.
@@ -281,12 +286,12 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification
}
func (n *notifier) newSuccessfulDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult {
n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), ResultSuccess).Inc()
n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), ResultSuccess).Inc()
return dispatchResult{
notifier: n.id,
msg: msg.ID,
ts: time.Now(),
ts: dbtime.Now(),
}
}
@@ -301,17 +306,27 @@ func (n *notifier) newFailedDispatch(msg database.AcquireNotificationMessagesRow
result = ResultPermFail
}
n.metrics.DispatchAttempts.WithLabelValues(string(n.method), msg.TemplateID.String(), result).Inc()
n.metrics.DispatchAttempts.WithLabelValues(string(msg.Method), msg.TemplateID.String(), result).Inc()
return dispatchResult{
notifier: n.id,
msg: msg.ID,
ts: time.Now(),
ts: dbtime.Now(),
err: err,
retryable: retryable,
}
}
func (n *notifier) newInhibitedDispatch(msg database.AcquireNotificationMessagesRow) dispatchResult {
return dispatchResult{
notifier: n.id,
msg: msg.ID,
ts: dbtime.Now(),
retryable: false,
inhibited: true,
}
}
// stop stops the notifier from processing any new notifications.
// This is a graceful stop, so any in-flight notifications will be completed before the notifier stops.
// Once a notifier has stopped, it cannot be restarted.
+228 -3
View File
@@ -5,19 +5,34 @@ import (
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/exp/slices"
"github.com/coder/serpent"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
"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 +56,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 +80,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)
@@ -93,3 +108,213 @@ func TestUpdateNotificationsSettings(t *testing.T) {
require.Equal(t, expected.NotifierPaused, actual.NotifierPaused)
})
}
func TestNotificationPreferences(t *testing.T) {
t.Parallel()
t.Run("Initial state", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member in its initial state.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: calling the API.
prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
// Then: no preferences will be returned.
require.Len(t, prefs, 0)
})
t.Run("Insufficient permissions", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: 2 members.
_, member1 := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
member2Client, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to retrieve the preferences of another member.
_, err := member2Client.GetUserNotificationPreferences(ctx, member1.ID)
// Then: the API should reject the request.
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
// NOTE: ExtractUserParam gets in the way here, and returns a 400 Bad Request instead of a 403 Forbidden.
// This is not ideal, and we should probably change this behavior.
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
})
t.Run("Admin may read any users' preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member.
_, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to retrieve the preferences of another member as an admin.
prefs, err := api.GetUserNotificationPreferences(ctx, member.ID)
// Then: the API should not reject the request.
require.NoError(t, err)
require.Len(t, prefs, 0)
})
t.Run("Admin may update any users' preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
// When: attempting to modify and subsequently retrieve the preferences of another member as an admin.
prefs, err := api.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceMarkedForDeletion.String(): true,
},
})
// Then: the request should succeed and the user should be able to query their own preferences to see the same result.
require.NoError(t, err)
require.Len(t, prefs, 1)
memberPrefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
require.Len(t, memberPrefs, 1)
require.Equal(t, prefs[0].NotificationTemplateID, memberPrefs[0].NotificationTemplateID)
require.Equal(t, prefs[0].Disabled, memberPrefs[0].Disabled)
})
t.Run("Add preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member with no preferences.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
prefs, err := memberClient.GetUserNotificationPreferences(ctx, member.ID)
require.NoError(t, err)
require.Len(t, prefs, 0)
// When: attempting to add new preferences.
template := notifications.TemplateWorkspaceDeleted
prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
template.String(): true,
},
})
// Then: the returning preferences should be set as expected.
require.NoError(t, err)
require.Len(t, prefs, 1)
require.Equal(t, prefs[0].NotificationTemplateID, template)
require.True(t, prefs[0].Disabled)
})
t.Run("Modify preferences", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
api := coderdtest.New(t, createOpts(t))
firstUser := coderdtest.CreateFirstUser(t, api)
// Given: a member with preferences.
memberClient, member := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID)
prefs, err := memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceDeleted.String(): true,
notifications.TemplateWorkspaceDormant.String(): true,
},
})
require.NoError(t, err)
require.Len(t, prefs, 2)
// When: attempting to modify their preferences.
prefs, err = memberClient.UpdateUserNotificationPreferences(ctx, member.ID, codersdk.UpdateUserNotificationPreferences{
TemplateDisabledMap: map[string]bool{
notifications.TemplateWorkspaceDeleted.String(): true,
notifications.TemplateWorkspaceDormant.String(): false, // <--- this one was changed
},
})
require.NoError(t, err)
require.Len(t, prefs, 2)
// Then: the modified preferences should be set as expected.
var found bool
for _, p := range prefs {
switch p.NotificationTemplateID {
case notifications.TemplateWorkspaceDormant:
found = true
require.False(t, p.Disabled)
case notifications.TemplateWorkspaceDeleted:
require.True(t, p.Disabled)
}
}
require.True(t, found, "dormant notification preference was not found")
})
}
func TestNotificationDispatchMethods(t *testing.T) {
t.Parallel()
defaultOpts := createOpts(t)
webhookOpts := createOpts(t)
webhookOpts.DeploymentValues.Notifications.Method = serpent.String(database.NotificationMethodWebhook)
tests := []struct {
name string
opts *coderdtest.Options
expectedDefault string
}{
{
name: "default",
opts: defaultOpts,
expectedDefault: string(database.NotificationMethodSmtp),
},
{
name: "non-default",
opts: webhookOpts,
expectedDefault: string(database.NotificationMethodWebhook),
},
}
var allMethods []string
for _, nm := range database.AllNotificationMethodValues() {
allMethods = append(allMethods, string(nm))
}
slices.Sort(allMethods)
// nolint:paralleltest // Not since Go v1.22.
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
api := coderdtest.New(t, tc.opts)
_ = coderdtest.CreateFirstUser(t, api)
resp, err := api.GetNotificationDispatchMethods(ctx)
require.NoError(t, err)
slices.Sort(resp.AvailableNotificationMethods)
require.EqualValues(t, resp.AvailableNotificationMethods, allMethods)
require.Equal(t, tc.expectedDefault, resp.DefaultNotificationMethod)
})
}
}
+18
View File
@@ -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,
+12
View File
@@ -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"),
},
},
}
+48
View File
@@ -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.WithOwner(uuid.NewString()), // some other user
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner},
false: {
memberMe, templateAdmin, orgUserAdmin, userAdmin,
orgAdmin, orgAuditor, orgTemplateAdmin,
otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin,
otherOrgAdmin, orgMemberMe,
},
},
},
// AnyOrganization tests
{
Name: "CreateOrgMember",
+3
View File
@@ -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"
}
+161
View File
@@ -3,13 +3,43 @@ package codersdk
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"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"`
}
type NotificationMethodsResponse struct {
AvailableNotificationMethods []string `json:"available"`
DefaultNotificationMethod string `json:"default"`
}
type NotificationPreference struct {
NotificationTemplateID uuid.UUID `json:"id" format:"uuid"`
Disabled bool `json:"disabled"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// 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 +53,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 +70,132 @@ 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
}
// GetUserNotificationPreferences retrieves notification preferences for a given user.
func (c *Client) GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var prefs []NotificationPreference
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &prefs); err != nil {
return nil, xerrors.Errorf("unmarshal response body: %w", err)
}
return prefs, nil
}
// UpdateUserNotificationPreferences updates notification preferences for a given user.
func (c *Client) UpdateUserNotificationPreferences(ctx context.Context, userID uuid.UUID, req UpdateUserNotificationPreferences) ([]NotificationPreference, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/notifications/preferences", userID.String()), req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var prefs []NotificationPreference
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &prefs); err != nil {
return nil, xerrors.Errorf("unmarshal response body: %w", err)
}
return prefs, nil
}
// GetNotificationDispatchMethods the available and default notification dispatch methods.
func (c *Client) GetNotificationDispatchMethods(ctx context.Context) (NotificationMethodsResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/dispatch-methods", nil)
if err != nil {
return NotificationMethodsResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return NotificationMethodsResponse{}, ReadBodyAsError(res)
}
var resp NotificationMethodsResponse
body, err := io.ReadAll(res.Body)
if err != nil {
return NotificationMethodsResponse{}, xerrors.Errorf("read response body: %w", err)
}
if err := json.Unmarshal(body, &resp); err != nil {
return NotificationMethodsResponse{}, xerrors.Errorf("unmarshal response body: %w", err)
}
return resp, nil
}
type UpdateNotificationTemplateMethod struct {
Method string `json:"method,omitempty" example:"webhook"`
}
type UpdateUserNotificationPreferences struct {
TemplateDisabledMap map[string]bool `json:"template_disabled_map"`
}
+56 -52
View File
@@ -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},
}
+1
View File
@@ -18,6 +18,7 @@ We track the following resources:
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>callback_url</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
+27
View File
@@ -537,6 +537,33 @@ 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`
### Parameters
| Name | In | Type | Required | Description |
| ----------------------- | ---- | ------ | -------- | -------------------------- |
| `notification_template` | path | string | true | Notification template UUID |
### Responses
| Status | Meaning | Description | Schema |
| ------ | --------------------------------------------------------------- | ------------ | ------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Success | |
| 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 OAuth2 applications.
### Code samples
-78
View File
@@ -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
+129 -123
View File
@@ -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).
+296
View File
@@ -0,0 +1,296 @@
# Notifications
## Get notification dispatch methods
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/notifications/dispatch-methods \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /notifications/dispatch-methods`
### Example responses
> 200 Response
```json
[
{
"available": ["string"],
"default": "string"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationMethodsResponse](schemas.md#codersdknotificationmethodsresponse) |
<h3 id="get-notification-dispatch-methods-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ----------- |
| `[array item]` | array | false | | |
| `» available` | array | false | | |
| `» default` | string | false | | |
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).
## 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) |
<h3 id="get-system-notification-templates-responseschema">Response Schema</h3>
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).
## Get user notification preferences
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/notifications/preferences \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/notifications/preferences`
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | ------ | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
### Example responses
> 200 Response
```json
[
{
"disabled": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"updated_at": "2019-08-24T14:15:22Z"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) |
<h3 id="get-user-notification-preferences-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| -------------- | ----------------- | -------- | ------------ | ----------- |
| `[array item]` | array | false | | |
| `» disabled` | boolean | false | | |
| `» id` | string(uuid) | false | | |
| `» updated_at` | string(date-time) | false | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Update user notification preferences
### Code samples
```shell
# Example request using curl
curl -X PUT http://coder-server:8080/api/v2/users/{user}/notifications/preferences \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`PUT /users/{user}/notifications/preferences`
> Body parameter
```json
{
"template_disabled_map": {
"property1": true,
"property2": true
}
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | -------------------------------------------------------------------------------------------------- | -------- | -------------------- |
| `user` | path | string | true | User ID, name, or me |
| `body` | body | [codersdk.UpdateUserNotificationPreferences](schemas.md#codersdkupdateusernotificationpreferences) | true | Preferences |
### Example responses
> 200 Response
```json
[
{
"disabled": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"updated_at": "2019-08-24T14:15:22Z"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.NotificationPreference](schemas.md#codersdknotificationpreference) |
<h3 id="update-user-notification-preferences-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| -------------- | ----------------- | -------- | ------------ | ----------- |
| `[array item]` | array | false | | |
| `» disabled` | boolean | false | | |
| `» id` | string(uuid) | false | | |
| `» updated_at` | string(date-time) | false | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+110 -28
View File
@@ -3141,6 +3141,68 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `id` | string | true | | |
| `username` | string | true | | |
## codersdk.NotificationMethodsResponse
```json
{
"available": ["string"],
"default": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ----------- | --------------- | -------- | ------------ | ----------- |
| `available` | array of string | false | | |
| `default` | string | false | | |
## codersdk.NotificationPreference
```json
{
"disabled": true,
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------ | ------- | -------- | ------------ | ----------- |
| `disabled` | boolean | false | | |
| `id` | string | false | | |
| `updated_at` | string | false | | |
## 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
@@ -4153,34 +4215,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
@@ -5535,6 +5599,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| ------------------ | ------ | -------- | ------------ | ----------- |
| `theme_preference` | string | true | | |
## codersdk.UpdateUserNotificationPreferences
```json
{
"template_disabled_map": {
"property1": true,
"property2": true
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ----------------------- | ------- | -------- | ------------ | ----------- |
| `template_disabled_map` | object | false | | |
| » `[any property]` | boolean | false | | |
## codersdk.UpdateUserPasswordRequest
```json
+4
View File
@@ -601,6 +601,10 @@
"title": "Members",
"path": "./api/members.md"
},
{
"title": "Notifications",
"path": "./api/notifications.md"
},
{
"title": "Organizations",
"path": "./api/organizations.md"
+7
View File
@@ -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:
+10
View File
@@ -272,6 +272,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
+9 -1
View File
@@ -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 {
+98
View File
@@ -0,0 +1,98 @@
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 update-notification-template-dispatch-method
// @Security CoderSessionToken
// @Produce json
// @Param notification_template path string true "Notification template UUID"
// @Tags Enterprise
// @Success 200 "Success"
// @Success 304 "Not modified"
// @Router /notifications/templates/{notification_template}/method [put]
func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http.Request) {
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.",
})
}
+180
View File
@@ -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/dbtestutil"
"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) *coderdenttest.Options {
t.Helper()
dt := coderdtest.DeploymentValues(t)
dt.Experiments = []string{string(codersdk.ExperimentNotifications)}
return &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dt,
},
}
}
func TestUpdateNotificationTemplateMethod(t *testing.T) {
t.Parallel()
t.Run("Happy path", func(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
api, _ := coderdenttest.New(t, createOpts(t))
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()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Given: the first user which has an "owner" role, and another user which does not.
api, firstUser := coderdenttest.New(t, createOpts(t))
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()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Given: the first user which has an "owner" role
api, _ := coderdenttest.New(t, createOpts(t))
// When: calling the API with an invalid method.
const method = "nope"
// nolint:gocritic // Using an owner-scope user is kinda the point.
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()
if !dbtestutil.WillUsePostgres() {
t.Skip("This test requires postgres; it relies on read from and writing to the notification_templates table")
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
api, _ := coderdenttest.New(t, createOpts(t))
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)
})
}
// nolint:revive // t takes precedence.
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.ID == uuid.Nil {
return nil, xerrors.Errorf("template not found: %q", id.String())
}
return &template, nil
}
+8
View File
@@ -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",
+39
View File
@@ -709,6 +709,31 @@ export interface MinimalUser {
readonly avatar_url: string;
}
// From codersdk/notifications.go
export interface NotificationMethodsResponse {
readonly available: readonly string[];
readonly default: string;
}
// From codersdk/notifications.go
export interface NotificationPreference {
readonly id: string;
readonly disabled: boolean;
readonly updated_at: 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;
@@ -1447,6 +1472,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;
@@ -1495,6 +1525,11 @@ export interface UpdateUserAppearanceSettingsRequest {
readonly theme_preference: string;
}
// From codersdk/notifications.go
export interface UpdateUserNotificationPreferences {
readonly template_disabled_map: Record<string, boolean>;
}
// From codersdk/users.go
export interface UpdateUserPasswordRequest {
readonly old_password: string;
@@ -2269,6 +2304,8 @@ export type RBACResource =
| "file"
| "group"
| "license"
| "notification_preference"
| "notification_template"
| "oauth2_app"
| "oauth2_app_code_token"
| "oauth2_app_secret"
@@ -2296,6 +2333,8 @@ export const RBACResources: RBACResource[] = [
"file",
"group",
"license",
"notification_preference",
"notification_template",
"oauth2_app",
"oauth2_app_code_token",
"oauth2_app_secret",