From 1e49190e12d3601aa68233b2cdce6b61b1bd81b2 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Fri, 15 Dec 2023 01:33:51 -0800 Subject: [PATCH] feat: add server flag to disable user custom quiet hours (#11124) --- 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 | 41 ++++++ site/src/api/typesGenerated.ts | 2 + .../SchedulePage/ScheduleForm.stories.tsx | 2 + .../SchedulePage/ScheduleForm.tsx | 13 +- .../SchedulePage/SchedulePage.test.tsx | 38 ++++- 22 files changed, 358 insertions(+), 46 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 e969ba780b..80849f1904 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 (default: CRON_TZ=UTC 0 0 * * *) 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 5b5a48dfaf..ef071c8b29 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -457,3 +457,8 @@ userQuietHoursSchedule: # comma separated values are not supported). # (default: CRON_TZ=UTC 0 0 * * *, type: string) defaultQuietHoursSchedule: CRON_TZ=UTC 0 0 * * * + # 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 1cdbea6ee2..569ade9033 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11353,6 +11353,9 @@ const docTemplate = `{ "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { + "allow_user_custom": { + "type": "boolean" + }, "default_schedule": { "type": "string" } @@ -11377,6 +11380,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 68cd8ed825..30d83cc50c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10292,6 +10292,9 @@ "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { + "allow_user_custom": { + "type": "boolean" + }, "default_schedule": { "type": "string" } @@ -10316,6 +10319,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 2ba9ce1621..47b701a63b 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 682775cf31..1e8a4c93cc 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -390,6 +390,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"` @@ -1821,6 +1822,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 fbf0f003fb..fa3aed72b1 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -107,6 +107,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 a9cc563dcc..b25e62c39b 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1352,6 +1352,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 } ] @@ -1367,14 +1368,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). @@ -1418,6 +1420,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 } ] @@ -1433,14 +1436,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 92921d4e23..043913bbf2 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 118952b50f..21eb6dba26 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2324,6 +2324,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, @@ -2700,6 +2701,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, @@ -5659,15 +5661,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 @@ -5677,19 +5681,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 d0a084883a..34fe77b3a5 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 230d07dbda..ad404185f0 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 (default: CRON_TZ=UTC 0 0 * * *) 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 a4ac8733a6..d7d2705d4b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -569,7 +569,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.Logger.Warn(ctx, "template autostop requirement will default to UTC midnight as the default user quiet hours schedule. Set a custom default quiet hours schedule using CODER_QUIET_HOURS_DEFAULT_SCHEDULE to avoid this warning") api.DefaultQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" } - 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 5045048061..ac158a795b 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) @@ -490,7 +490,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 49c2b61b30..c117427e4f 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 0000000000..5e1685a42e --- /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 00c3ecaf0a..935eeb8f6e 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" ) @@ -62,6 +65,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())), @@ -98,7 +102,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) @@ -108,6 +117,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 40c06fbf33..05bfa80e87 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -176,4 +176,45 @@ func TestUserQuietHours(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) }) + + t.Run("UserCannotSet", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + 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, + }, + }, + }) + + // 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 c8887832c3..934f2681fd 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1368,12 +1368,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 db90165e45..7e3054212e 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 3810457576..932dc4acd6 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. + + )} + > = ({
diff --git a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx index 06f2fcdd43..165a34ba20 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.test.tsx @@ -37,6 +37,7 @@ const submitForm = async () => { const defaultQuietHoursResponse = { raw_schedule: "CRON_TZ=America/Chicago 0 0 * * *", user_set: false, + user_can_set: true, time: "00:00", timezone: "America/Chicago", next: "", // not consumed by the frontend @@ -52,7 +53,6 @@ const cronTests = [ describe("SchedulePage", () => { beforeEach(() => { - // appear logged out server.use( rest.get(`/api/v2/users/${MockUser.id}/quiet-hours`, (req, res, ctx) => { return res(ctx.status(200), ctx.json(defaultQuietHoursResponse)); @@ -72,7 +72,6 @@ describe("SchedulePage", () => { return res( ctx.status(200), ctx.json({ - response: {}, raw_schedule: data.schedule, user_set: true, time: `${test.hour.toString().padStart(2, "0")}:${test.minute @@ -121,4 +120,39 @@ describe("SchedulePage", () => { expect(errorMessage).toBeDefined(); }); }); + + describe("when user custom schedule is disabled", () => { + it("shows a warning and disables the form", async () => { + server.use( + rest.get( + `/api/v2/users/${MockUser.id}/quiet-hours`, + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + raw_schedule: "CRON_TZ=America/Chicago 0 0 * * *", + user_can_set: false, + user_set: false, + time: "00:00", + timezone: "America/Chicago", + next: "", // not consumed by the frontend + }), + ); + }, + ), + ); + + renderWithAuth(); + await screen.findByText( + "Your administrator has disabled the ability to set a custom quiet hours schedule.", + ); + + const timeInput = screen.getByLabelText("Start time"); + expect(timeInput).toBeDisabled(); + const timezoneDropdown = screen.getByLabelText("Timezone"); + expect(timezoneDropdown).toHaveClass("Mui-disabled"); + const updateButton = screen.getByText("Update schedule"); + expect(updateButton).toBeDisabled(); + }); + }); });