From 99e3556fb8ca32b777ddad73eb7d085ba9e7fa22 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Mon, 11 Dec 2023 12:59:22 +0000 Subject: [PATCH] feat: add server flag to disable user custom quiet hours --- cli/testdata/coder_server_--help.golden | 6 + cli/testdata/server-config.yaml.golden | 5 + coderd/apidoc/docs.go | 7 + coderd/apidoc/swagger.json | 7 + coderd/schedule/user.go | 22 +-- codersdk/deployment.go | 11 ++ codersdk/users.go | 4 + docs/api/enterprise.md | 36 ++--- docs/api/general.md | 1 + docs/api/schemas.md | 26 ++-- docs/cli/server.md | 11 ++ .../cli/testdata/coder_server_--help.golden | 6 + enterprise/coderd/coderd.go | 2 +- enterprise/coderd/schedule/template_test.go | 4 +- enterprise/coderd/schedule/user.go | 17 ++- enterprise/coderd/schedule/user_test.go | 131 ++++++++++++++++++ enterprise/coderd/users.go | 12 +- enterprise/coderd/users_test.go | 43 ++++++ site/src/api/typesGenerated.ts | 2 + .../SchedulePage/ScheduleForm.stories.tsx | 2 + .../SchedulePage/ScheduleForm.tsx | 13 +- 21 files changed, 324 insertions(+), 44 deletions(-) create mode 100644 enterprise/coderd/schedule/user_test.go diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 72c1c2bee7334..829c4a176cd00 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -447,6 +447,12 @@ USER QUIET HOURS SCHEDULE OPTIONS: Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL. + --allow-custom-quiet-hours bool, $CODER_ALLOW_CUSTOM_QUIET_HOURS (default: true) + Allow users to set their own quiet hours schedule for workspaces to + stop in (depending on template autostop requirement settings). If + false, users can't change their quiet hours schedule and the site + default is always used. + --default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 2346f73d112d1..fc9fb069d3344 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -457,3 +457,8 @@ userQuietHoursSchedule: # values are not supported). # (default: , type: string) defaultQuietHoursSchedule: "" + # Allow users to set their own quiet hours schedule for workspaces to stop in + # (depending on template autostop requirement settings). If false, users can't + # change their quiet hours schedule and the site default is always used. + # (default: true, type: bool) + allowCustomQuietHours: true diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6230de3233d97..6e0b54b5b4c59 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11292,6 +11292,9 @@ const docTemplate = `{ "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { + "allow_user_custom": { + "type": "boolean" + }, "default_schedule": { "type": "string" } @@ -11316,6 +11319,10 @@ const docTemplate = `{ "description": "raw format from the cron expression, UTC if unspecified", "type": "string" }, + "user_can_set": { + "description": "UserCanSet is true if the user is allowed to set their own quiet hours\nschedule. If false, the user cannot set a custom schedule and the default\nschedule will always be used.", + "type": "boolean" + }, "user_set": { "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", "type": "boolean" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0fa7b856f0dc2..a3149571a7867 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10239,6 +10239,9 @@ "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { + "allow_user_custom": { + "type": "boolean" + }, "default_schedule": { "type": "string" } @@ -10263,6 +10266,10 @@ "description": "raw format from the cron expression, UTC if unspecified", "type": "string" }, + "user_can_set": { + "description": "UserCanSet is true if the user is allowed to set their own quiet hours\nschedule. If false, the user cannot set a custom schedule and the default\nschedule will always be used.", + "type": "boolean" + }, "user_set": { "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", "type": "boolean" diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go index 2ba9ce1621e37..47b701a63b78d 100644 --- a/coderd/schedule/user.go +++ b/coderd/schedule/user.go @@ -4,11 +4,14 @@ import ( "context" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/schedule/cron" ) +var ErrUserCannotSetQuietHoursSchedule = xerrors.New("user cannot set custom quiet hours schedule due to deployment configuration") + type UserQuietHoursScheduleOptions struct { // Schedule is the cron schedule to use for quiet hours windows for all // workspaces owned by the user. @@ -19,7 +22,13 @@ type UserQuietHoursScheduleOptions struct { // entitled or disabled instance-wide, this value will be nil to denote that // quiet hours windows should not be used. Schedule *cron.Schedule - UserSet bool + // UserSet is true if the user has set a custom schedule, false if the + // default schedule is being used. + UserSet bool + // UserCanSet is true if the user is allowed to set a custom schedule. If + // false, the user cannot set a custom schedule and the default schedule + // will always be used. + UserCanSet bool } type UserQuietHoursScheduleStore interface { @@ -47,15 +56,12 @@ func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore { func (*agplUserQuietHoursScheduleStore) Get(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) { // User quiet hours windows are not supported in AGPL. return UserQuietHoursScheduleOptions{ - Schedule: nil, - UserSet: false, + Schedule: nil, + UserSet: false, + UserCanSet: false, }, nil } func (*agplUserQuietHoursScheduleStore) Set(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) { - // User quiet hours windows are not supported in AGPL. - return UserQuietHoursScheduleOptions{ - Schedule: nil, - UserSet: false, - }, nil + return UserQuietHoursScheduleOptions{}, ErrUserCannotSetQuietHoursSchedule } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index ec2fcc94df1fe..7c6e712fdf322 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -393,6 +393,7 @@ type DangerousConfig struct { type UserQuietHoursScheduleConfig struct { DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"` + AllowUserCustom clibase.Bool `json:"allow_user_custom" typescript:",notnull"` // TODO: add WindowDuration and the ability to postpone max_deadline by this // amount // WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"` @@ -1824,6 +1825,16 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupUserQuietHoursSchedule, YAML: "defaultQuietHoursSchedule", }, + { + Name: "Allow Custom Quiet Hours", + Description: "Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.", + Flag: "allow-custom-quiet-hours", + Env: "CODER_ALLOW_CUSTOM_QUIET_HOURS", + Default: "true", + Value: &c.UserQuietHoursSchedule.AllowUserCustom, + Group: &deploymentGroupUserQuietHoursSchedule, + YAML: "allowCustomQuietHours", + }, { Name: "Web Terminal Renderer", Description: "The renderer to use when opening a web terminal. Valid values are 'canvas', 'webgl', or 'dom'.", diff --git a/codersdk/users.go b/codersdk/users.go index c11846ebdac2b..7ec5ba2d31f92 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -102,6 +102,10 @@ type UserQuietHoursScheduleResponse struct { // UserSet is true if the user has set their own quiet hours schedule. If // false, the user is using the default schedule. UserSet bool `json:"user_set"` + // UserCanSet is true if the user is allowed to set their own quiet hours + // schedule. If false, the user cannot set a custom schedule and the default + // schedule will always be used. + UserCanSet bool `json:"user_can_set"` // Time is the time of day that the quiet hours window starts in the given // Timezone each day. Time string `json:"time"` // HH:mm (24-hour) diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index 5e6361698b35b..d69e19f6d372f 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1341,6 +1341,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \ "raw_schedule": "string", "time": "string", "timezone": "string", + "user_can_set": true, "user_set": true } ] @@ -1356,14 +1357,15 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | -| `» raw_schedule` | string | false | | | -| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | -| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | -| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -1407,6 +1409,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \ "raw_schedule": "string", "time": "string", "timezone": "string", + "user_can_set": true, "user_set": true } ] @@ -1422,14 +1425,15 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | -| `» raw_schedule` | string | false | | | -| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | -| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | -| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/general.md b/docs/api/general.md index 92921d4e23655..043913bbf2eb8 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -394,6 +394,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "update_check": true, "user_quiet_hours_schedule": { + "allow_user_custom": true, "default_schedule": "string" }, "verbose": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3e21db9288f21..ab612c45d7a61 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2320,6 +2320,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "update_check": true, "user_quiet_hours_schedule": { + "allow_user_custom": true, "default_schedule": "string" }, "verbose": true, @@ -2696,6 +2697,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "update_check": true, "user_quiet_hours_schedule": { + "allow_user_custom": true, "default_schedule": "string" }, "verbose": true, @@ -5636,15 +5638,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { + "allow_user_custom": true, "default_schedule": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------ | -------- | ------------ | ----------- | -| `default_schedule` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------- | ------- | -------- | ------------ | ----------- | +| `allow_user_custom` | boolean | false | | | +| `default_schedule` | string | false | | | ## codersdk.UserQuietHoursScheduleResponse @@ -5654,19 +5658,21 @@ If the schedule is empty, the user will be updated to use the default schedule.| "raw_schedule": "string", "time": "string", "timezone": "string", + "user_can_set": true, "user_set": true } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | -| `next` | string | false | | Next is the next time that the quiet hours window will start. | -| `raw_schedule` | string | false | | | -| `time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | -| `timezone` | string | false | | raw format from the cron expression, UTC if unspecified | -| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `next` | string | false | | Next is the next time that the quiet hours window will start. | +| `raw_schedule` | string | false | | | +| `time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `user_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. | +| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | ## codersdk.UserStatus diff --git a/docs/cli/server.md b/docs/cli/server.md index 2b700e09568f8..42ce7b715ac5e 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -31,6 +31,17 @@ coder server [flags] The URL that users will use to access the Coder deployment. +### --allow-custom-quiet-hours + +| | | +| ----------- | --------------------------------------------------------- | +| Type | bool | +| Environment | $CODER_ALLOW_CUSTOM_QUIET_HOURS | +| YAML | userQuietHoursSchedule.allowCustomQuietHours | +| Default | true | + +Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used. + ### --block-direct-connections | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 6997e74260317..f9625144251ec 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -448,6 +448,12 @@ USER QUIET HOURS SCHEDULE OPTIONS: Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL. + --allow-custom-quiet-hours bool, $CODER_ALLOW_CUSTOM_QUIET_HOURS (default: true) + Allow users to set their own quiet hours schedule for workspaces to + stop in (depending on template autostop requirement settings). If + false, users can't change their quiet hours schedule and the site + default is always used. + --default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 028ae5a6768c1..b8988e9151975 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -587,7 +587,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { } enterpriseTemplateStore.UseAutostopRequirement.Store(true) - quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule) + quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.DeploymentValues.UserQuietHoursSchedule.AllowUserCustom.Value()) if err != nil { api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template autostop requirements will not be applied to workspace builds", slog.Error(err)) } else { diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 1fdbd6ba241dd..54429b285d109 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -207,7 +207,7 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) { wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID) require.NoError(t, err) - userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule) + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) require.NoError(t, err) userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} userQuietHoursStorePtr.Store(&userQuietHoursStore) @@ -491,7 +491,7 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) { require.NoError(t, err) } - userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule) + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) require.NoError(t, err) userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} userQuietHoursStorePtr.Store(&userQuietHoursStore) diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go index 49c2b61b30e99..c117427e4f8c9 100644 --- a/enterprise/coderd/schedule/user.go +++ b/enterprise/coderd/schedule/user.go @@ -18,17 +18,19 @@ import ( // enterprise customers. type enterpriseUserQuietHoursScheduleStore struct { defaultSchedule string + userCanSet bool } var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{} -func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.UserQuietHoursScheduleStore, error) { +func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string, userCanSet bool) (agpl.UserQuietHoursScheduleStore, error) { if defaultSchedule == "" { return nil, xerrors.Errorf("default schedule must be set") } s := &enterpriseUserQuietHoursScheduleStore{ defaultSchedule: defaultSchedule, + userCanSet: userCanSet, } // The context is only used for tracing so using a background ctx is fine. @@ -64,8 +66,9 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(ctx context.Contex } return agpl.UserQuietHoursScheduleOptions{ - Schedule: sched, - UserSet: userSet, + Schedule: sched, + UserSet: userSet, + UserCanSet: s.userCanSet, }, nil } @@ -73,6 +76,10 @@ func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db data ctx, span := tracing.StartSpan(ctx) defer span.End() + if !s.userCanSet { + return s.parseSchedule(ctx, "") + } + user, err := db.GetUserByID(ctx, userID) if err != nil { return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) @@ -85,6 +92,10 @@ func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db data ctx, span := tracing.StartSpan(ctx) defer span.End() + if !s.userCanSet { + return agpl.UserQuietHoursScheduleOptions{}, agpl.ErrUserCannotSetQuietHoursSchedule + } + opts, err := s.parseSchedule(ctx, rawSchedule) if err != nil { return opts, err diff --git a/enterprise/coderd/schedule/user_test.go b/enterprise/coderd/schedule/user_test.go new file mode 100644 index 0000000000000..5e1685a42e2c2 --- /dev/null +++ b/enterprise/coderd/schedule/user_test.go @@ -0,0 +1,131 @@ +package schedule_test + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmock" + agpl "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/enterprise/coderd/schedule" +) + +func TestEnterpriseUserQuietHoursSchedule(t *testing.T) { + t.Parallel() + + const ( + defaultSchedule = "CRON_TZ=UTC 15 10 * * *" + userCustomSchedule1 = "CRON_TZ=Australia/Sydney 30 2 * * *" + userCustomSchedule2 = "CRON_TZ=Australia/Sydney 0 18 * * *" + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + userID := uuid.New() + s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true) + require.NoError(t, err) + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + // User has no schedule set, use default. + mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{}, nil).Times(1) + opts, err := s.Get(context.Background(), mDB, userID) + require.NoError(t, err) + require.NotNil(t, opts.Schedule) + require.Equal(t, defaultSchedule, opts.Schedule.String()) + require.False(t, opts.UserSet) + require.True(t, opts.UserCanSet) + + // User has a custom schedule set. + mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{ + QuietHoursSchedule: userCustomSchedule1, + }, nil).Times(1) + opts, err = s.Get(context.Background(), mDB, userID) + require.NoError(t, err) + require.NotNil(t, opts.Schedule) + require.Equal(t, userCustomSchedule1, opts.Schedule.String()) + require.True(t, opts.UserSet) + require.True(t, opts.UserCanSet) + + // Set user schedule. + mDB.EXPECT().UpdateUserQuietHoursSchedule(gomock.Any(), database.UpdateUserQuietHoursScheduleParams{ + ID: userID, + QuietHoursSchedule: userCustomSchedule2, + }).Return(database.User{}, nil).Times(1) + opts, err = s.Set(context.Background(), mDB, userID, userCustomSchedule2) + require.NoError(t, err) + require.NotNil(t, opts.Schedule) + require.Equal(t, userCustomSchedule2, opts.Schedule.String()) + require.True(t, opts.UserSet) + }) + + t.Run("BadDefaultSchedule", func(t *testing.T) { + t.Parallel() + + _, err := schedule.NewEnterpriseUserQuietHoursScheduleStore("bad schedule", true) + require.Error(t, err) + require.ErrorContains(t, err, `parse daily schedule "bad schedule"`) + }) + + t.Run("BadGotSchedule", func(t *testing.T) { + t.Parallel() + + userID := uuid.New() + s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true) + require.NoError(t, err) + + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + // User has a custom schedule set. + mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{ + QuietHoursSchedule: "bad schedule", + }, nil).Times(1) + _, err = s.Get(context.Background(), mDB, userID) + require.Error(t, err) + require.ErrorContains(t, err, `parse daily schedule "bad schedule"`) + }) + + t.Run("BadSetSchedule", func(t *testing.T) { + t.Parallel() + + s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true) + require.NoError(t, err) + + // Use the mock DB here. It won't get used, but if it ever does it will + // fail the test. + mDB := dbmock.NewMockStore(gomock.NewController(t)) + _, err = s.Set(context.Background(), mDB, uuid.New(), "bad schedule") + require.Error(t, err) + require.ErrorContains(t, err, `parse daily schedule "bad schedule"`) + }) + + t.Run("UserCannotSet", func(t *testing.T) { + t.Parallel() + + userID := uuid.New() + s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, false) // <--- + require.NoError(t, err) + + // Use the mock DB here. It won't get used, but if it ever does it will + // fail the test. + mDB := dbmock.NewMockStore(gomock.NewController(t)) + + // Should never reach out to DB to check user's custom schedule. + opts, err := s.Get(context.Background(), mDB, userID) + require.NoError(t, err) + require.NotNil(t, opts.Schedule) + require.Equal(t, defaultSchedule, opts.Schedule.String()) + require.False(t, opts.UserSet) + require.False(t, opts.UserCanSet) + + // Set user schedule should fail. + _, err = s.Set(context.Background(), mDB, userID, userCustomSchedule1) + require.Error(t, err) + require.ErrorIs(t, err, agpl.ErrUserCannotSetQuietHoursSchedule) + }) +} diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 6398a93c95e85..227529748014e 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -4,10 +4,13 @@ import ( "net/http" "time" + "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/coderd/schedule" "github.com/coder/coder/v2/codersdk" ) @@ -68,6 +71,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, + UserCanSet: opts.UserCanSet, Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), @@ -104,7 +108,12 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques } opts, err := (*api.UserQuietHoursScheduleStore.Load()).Set(ctx, api.Database, user.ID, params.Schedule) - if err != nil { + if xerrors.Is(err, schedule.ErrUserCannotSetQuietHoursSchedule) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Users cannot set custom quiet hours schedule due to deployment configuration.", + }) + return + } else if err != nil { // TODO(@dean): some of these errors are related to bad syntax, so it // would be nice to 400 instead httpapi.InternalServerError(rw, err) @@ -114,6 +123,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, + UserCanSet: opts.UserCanSet, Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index de7865f7d3666..325346e2276dd 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -217,4 +217,47 @@ func TestUserQuietHours(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) }) + + t.Run("UserCannotSet", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments.Set(string(codersdk.ExperimentTemplateAutostopRequirement)) + dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *") + dv.UserQuietHoursSchedule.AllowUserCustom.Set("false") + + adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureTemplateAutostopRequirement: 1, + }, + }, + }) + + // Do it with another user to make sure that we're not hitting RBAC + // errors. + client, user := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + + // Get the schedule + ctx := testutil.Context(t, testutil.WaitLong) + sched, err := client.UserQuietHoursSchedule(ctx, user.ID.String()) + require.NoError(t, err) + require.Equal(t, "CRON_TZ=America/Chicago 0 0 * * *", sched.RawSchedule) + require.False(t, sched.UserSet) + require.False(t, sched.UserCanSet) + + // Try to set + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.ID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "CRON_TZ=America/Chicago 30 2 * * *", + }) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "cannot set custom quiet hours schedule") + }) } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 81c9df50a5922..5ed2dfc575ced 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1362,12 +1362,14 @@ export interface UserLoginType { // From codersdk/deployment.go export interface UserQuietHoursScheduleConfig { readonly default_schedule: string; + readonly allow_user_custom: boolean; } // From codersdk/users.go export interface UserQuietHoursScheduleResponse { readonly raw_schedule: string; readonly user_set: boolean; + readonly user_can_set: boolean; readonly time: string; readonly timezone: string; readonly next: string; diff --git a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx index db90165e45bde..7e3054212e5fe 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx @@ -8,6 +8,7 @@ const defaultArgs = { initialValues: { raw_schedule: "CRON_TZ=Australia/Sydney 0 2 * * *", user_set: false, + user_can_set: true, time: "02:00", timezone: "Australia/Sydney", next: "2023-09-05T02:00:00+10:00", @@ -33,6 +34,7 @@ export const ExampleUserSet: Story = { initialValues: { raw_schedule: "CRON_TZ=America/Chicago 0 2 * * *", user_set: true, + user_can_set: true, time: "02:00", timezone: "America/Chicago", next: "2023-09-05T02:00:00-05:00", diff --git a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx index 38104575761b7..932dc4acd6f9d 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx @@ -93,17 +93,24 @@ export const ScheduleForm: FC> = ({ )} + {!initialValues.user_can_set && ( + + Your administrator has disabled the ability to set a custom quiet + hours schedule. + + )} + > = ({