diff --git a/api4/config.go b/api4/config.go index 4d5a04efcc..0515f14b91 100644 --- a/api4/config.go +++ b/api4/config.go @@ -15,6 +15,7 @@ import ( func (api *API) InitConfig() { api.BaseRoutes.ApiRoot.Handle("/config", api.ApiSessionRequired(getConfig)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/config", api.ApiSessionRequired(updateConfig)).Methods("PUT") + api.BaseRoutes.ApiRoot.Handle("/config/patch", api.ApiSessionRequired(patchConfig)).Methods("PUT") api.BaseRoutes.ApiRoot.Handle("/config/reload", api.ApiSessionRequired(configReload)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/config/client", api.ApiHandler(getClientConfig)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/config/environment", api.ApiSessionRequired(getEnvironmentConfig)).Methods("GET") @@ -81,19 +82,7 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) { // Do not allow plugin uploads to be toggled through the API cfg.PluginSettings.EnableUploads = appCfg.PluginSettings.EnableUploads - // If the Message Export feature has been toggled in the System Console, rewrite the ExportFromTimestamp field to an - // appropriate value. The rewriting occurs here to ensure it doesn't affect values written to the config file - // directly and not through the System Console UI. - if *cfg.MessageExportSettings.EnableExport != *appCfg.MessageExportSettings.EnableExport { - if *cfg.MessageExportSettings.EnableExport && *cfg.MessageExportSettings.ExportFromTimestamp == int64(0) { - // When the feature is toggled on, use the current timestamp as the start time for future exports. - cfg.MessageExportSettings.ExportFromTimestamp = model.NewInt64(model.GetMillis()) - } else if !*cfg.MessageExportSettings.EnableExport { - // When the feature is disabled, reset the timestamp so that the timestamp will be set if - // the feature is re-enabled from the System Console in future. - cfg.MessageExportSettings.ExportFromTimestamp = model.NewInt64(0) - } - } + c.App.HandleMessageExportConfig(cfg, appCfg) err := cfg.IsValid() if err != nil { @@ -149,3 +138,59 @@ func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Write([]byte(model.StringInterfaceToJson(envConfig))) } + +func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { + cfg := model.ConfigFromJson(r.Body) + if cfg == nil { + c.SetInvalidParam("config") + return + } + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + appCfg := c.App.Config() + var filterFn utils.StructFieldFilter + if *appCfg.ExperimentalSettings.RestrictSystemAdmin { + filterFn = func(structField reflect.StructField, base, patch reflect.Value) bool { + return !(structField.Tag.Get("restricted") == "true") + } + } else { + filterFn = func(structField reflect.StructField, base, patch reflect.Value) bool { + return true + } + } + + // Do not allow plugin uploads to be toggled through the API + cfg.PluginSettings.EnableUploads = appCfg.PluginSettings.EnableUploads + + if cfg.MessageExportSettings.EnableExport != nil { + c.App.HandleMessageExportConfig(cfg, appCfg) + } + + updatedCfg, mergeErr := config.Merge(appCfg, cfg, &utils.MergeConfig{ + StructFieldFilter: filterFn, + }) + + if mergeErr != nil { + c.Err = model.NewAppError("patchConfig", "api.config.update_config.restricted_merge.app_error", nil, mergeErr.Error(), http.StatusInternalServerError) + return + } + + err := updatedCfg.IsValid() + if err != nil { + c.Err = err + return + } + + err = c.App.SaveConfig(updatedCfg, true) + if err != nil { + c.Err = err + return + } + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(c.App.GetSanitizedConfig().ToJson())) +} diff --git a/api4/config_test.go b/api4/config_test.go index 095dea26b5..d89c4764b9 100644 --- a/api4/config_test.go +++ b/api4/config_test.go @@ -345,3 +345,100 @@ func TestGetOldClientConfig(t *testing.T) { } }) } + +func TestPatchConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + client := th.Client + + t.Run("config is missing", func(t *testing.T) { + _, response := client.PatchConfig(nil) + CheckBadRequestStatus(t, response) + }) + + t.Run("user is not system admin", func(t *testing.T) { + _, response := client.PatchConfig(&model.Config{}) + CheckForbiddenStatus(t, response) + }) + + t.Run("should not update the restricted fields when restrict toggle is on", func(t *testing.T) { + *th.App.Config().ExperimentalSettings.RestrictSystemAdmin = true + + config := model.Config{LogSettings: model.LogSettings{ + ConsoleLevel: model.NewString("INFO"), + }} + + updatedConfig, _ := th.SystemAdminClient.PatchConfig(&config) + + assert.Equal(t, "DEBUG", *updatedConfig.LogSettings.ConsoleLevel) + }) + + t.Run("check if config is valid", func(t *testing.T) { + config := model.Config{PasswordSettings: model.PasswordSettings{ + MinimumLength: model.NewInt(4), + }} + + _, response := th.SystemAdminClient.PatchConfig(&config) + + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + assert.NotNil(t, response.Error) + assert.Equal(t, "model.config.is_valid.password_length.app_error", response.Error.Id) + }) + + t.Run("should patch the config", func(t *testing.T) { + *th.App.Config().ExperimentalSettings.RestrictSystemAdmin = false + th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.ExperimentalDefaultChannels = []string{"some-channel"} }) + + oldConfig, _ := th.SystemAdminClient.GetConfig() + + assert.False(t, *oldConfig.PasswordSettings.Lowercase) + assert.NotEqual(t, 15, *oldConfig.PasswordSettings.MinimumLength) + assert.Equal(t, "DEBUG", *oldConfig.LogSettings.ConsoleLevel) + assert.True(t, oldConfig.PluginSettings.PluginStates["com.mattermost.nps"].Enable) + + states := make(map[string]*model.PluginState) + states["com.mattermost.nps"] = &model.PluginState{Enable: *model.NewBool(false)} + config := model.Config{PasswordSettings: model.PasswordSettings{ + Lowercase: model.NewBool(true), + MinimumLength: model.NewInt(15), + }, LogSettings: model.LogSettings{ + ConsoleLevel: model.NewString("INFO"), + }, + TeamSettings: model.TeamSettings{ + ExperimentalDefaultChannels: []string{"another-channel"}, + }, + PluginSettings: model.PluginSettings{ + PluginStates: states, + }, + } + + _, response := th.SystemAdminClient.PatchConfig(&config) + + updatedConfig, _ := th.SystemAdminClient.GetConfig() + assert.True(t, *updatedConfig.PasswordSettings.Lowercase) + assert.Equal(t, "INFO", *updatedConfig.LogSettings.ConsoleLevel) + assert.Equal(t, []string{"another-channel"}, updatedConfig.TeamSettings.ExperimentalDefaultChannels) + assert.False(t, updatedConfig.PluginSettings.PluginStates["com.mattermost.nps"].Enable) + assert.Equal(t, "no-cache, no-store, must-revalidate", response.Header.Get("Cache-Control")) + }) + + t.Run("should sanitize config", func(t *testing.T) { + config := model.Config{PasswordSettings: model.PasswordSettings{ + Symbol: model.NewBool(true), + }} + + updatedConfig, _ := th.SystemAdminClient.PatchConfig(&config) + + assert.Equal(t, model.FAKE_SETTING, *updatedConfig.SqlSettings.DataSource) + }) + + t.Run("not allowing to toggle enable uploads for plugin via api", func(t *testing.T) { + config := model.Config{PluginSettings: model.PluginSettings{ + EnableUploads: model.NewBool(true), + }} + + updatedConfig, _ := th.SystemAdminClient.PatchConfig(&config) + + assert.Equal(t, false, *updatedConfig.PluginSettings.EnableUploads) + }) +} diff --git a/app/config.go b/app/config.go index 5196626791..860dbc7bcb 100644 --- a/app/config.go +++ b/app/config.go @@ -420,3 +420,19 @@ func (a *App) IsESAutocompletionEnabled() bool { license := a.License() return esInterface != nil && *a.Config().ElasticsearchSettings.EnableAutocomplete && license != nil && *license.Features.Elasticsearch } + +func (a *App) HandleMessageExportConfig(cfg *model.Config, appCfg *model.Config) { + // If the Message Export feature has been toggled in the System Console, rewrite the ExportFromTimestamp field to an + // appropriate value. The rewriting occurs here to ensure it doesn't affect values written to the config file + // directly and not through the System Console UI. + if *cfg.MessageExportSettings.EnableExport != *appCfg.MessageExportSettings.EnableExport { + if *cfg.MessageExportSettings.EnableExport && *cfg.MessageExportSettings.ExportFromTimestamp == int64(0) { + // When the feature is toggled on, use the current timestamp as the start time for future exports. + cfg.MessageExportSettings.ExportFromTimestamp = model.NewInt64(model.GetMillis()) + } else if !*cfg.MessageExportSettings.EnableExport { + // When the feature is disabled, reset the timestamp so that the timestamp will be set if + // the feature is re-enabled from the System Console in future. + cfg.MessageExportSettings.ExportFromTimestamp = model.NewInt64(0) + } + } +} diff --git a/model/client4.go b/model/client4.go index b322510ccc..1d46889065 100644 --- a/model/client4.go +++ b/model/client4.go @@ -4827,3 +4827,12 @@ func (c *Client4) ChannelMembersMinusGroupMembers(channelID string, groupIDs []s ugc := UsersWithGroupsAndCountFromJson(r.Body) return ugc.Users, ugc.Count, BuildResponse(r) } + +func (c *Client4) PatchConfig(config *Config) (*Config, *Response) { + r, err := c.DoApiPut(c.GetConfigRoute()+"/patch", config.ToJson()) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return ConfigFromJson(r.Body), BuildResponse(r) +}