From 9ef8c1e8b1cb1d7133af406569a61dd41a2e5644 Mon Sep 17 00:00:00 2001 From: Jesse Hallam Date: Fri, 8 Mar 2019 13:15:28 -0500 Subject: [PATCH] MM-14439: experimental restrict system admin (#10414) * api4: break out license and config from system * app: move some config functions from admin.go to config.go * add ExperimentalSettings.RestrictSystemAdmin * forbid various actions to restricted system admin * update default.json * fix function names in errors --- api4/api.go | 2 + api4/cluster.go | 5 + api4/cluster_test.go | 27 +- api4/config.go | 125 +++++++++ api4/config_test.go | 326 +++++++++++++++++++++++ api4/elasticsearch.go | 10 + api4/elasticsearch_test.go | 40 ++- api4/license.go | 131 +++++++++ api4/license_test.go | 105 ++++++++ api4/system.go | 238 ++--------------- api4/system_test.go | 529 +++++++------------------------------ app/admin.go | 39 --- app/config.go | 41 +++ config/default.json | 3 +- i18n/en.json | 4 + model/config.go | 5 + 16 files changed, 929 insertions(+), 701 deletions(-) create mode 100644 api4/config.go create mode 100644 api4/config_test.go create mode 100644 api4/license.go create mode 100644 api4/license_test.go diff --git a/api4/api.go b/api4/api.go index f943a73a7e6..b9540716d72 100644 --- a/api4/api.go +++ b/api4/api.go @@ -221,6 +221,8 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.InitPost() api.InitFile() api.InitSystem() + api.InitLicense() + api.InitConfig() api.InitWebhook() api.InitPreference() api.InitSaml() diff --git a/api4/cluster.go b/api4/cluster.go index ccffeba2458..095e260abbe 100644 --- a/api4/cluster.go +++ b/api4/cluster.go @@ -19,6 +19,11 @@ func getClusterStatus(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("getClusterStatus", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + infos := c.App.GetClusterStatus() w.Write([]byte(model.ClusterInfosToJson(infos))) } diff --git a/api4/cluster_test.go b/api4/cluster_test.go index 3e0ba5212fa..abc1fb515ba 100644 --- a/api4/cluster_test.go +++ b/api4/cluster_test.go @@ -5,19 +5,32 @@ package api4 import ( "testing" + + "github.com/mattermost/mattermost-server/model" ) func TestGetClusterStatus(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() - _, resp := th.Client.GetClusterStatus() - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := th.Client.GetClusterStatus() + CheckForbiddenStatus(t, resp) + }) - infos, resp := th.SystemAdminClient.GetClusterStatus() - CheckNoError(t, resp) + t.Run("as system admin", func(t *testing.T) { + infos, resp := th.SystemAdminClient.GetClusterStatus() + CheckNoError(t, resp) - if infos == nil { - t.Fatal("should not be nil") - } + if infos == nil { + t.Fatal("should not be nil") + } + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.GetClusterStatus() + CheckForbiddenStatus(t, resp) + }) } diff --git a/api4/config.go b/api4/config.go new file mode 100644 index 00000000000..b55876284ad --- /dev/null +++ b/api4/config.go @@ -0,0 +1,125 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "net/http" + + "github.com/mattermost/mattermost-server/model" +) + +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/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") +} + +func getConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + cfg := c.App.GetSanitizedConfig() + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(cfg.ToJson())) +} + +func configReload(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("configReload", "api.restricted_system_admin", nil, "", http.StatusBadRequest) + return + } + + c.App.ReloadConfig() + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + ReturnStatusOK(w) +} + +func updateConfig(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 + } + + // Do not allow plugin uploads to be toggled through the API + cfg.PluginSettings.EnableUploads = c.App.Config().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 != *c.App.Config().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) + } + } + + err := c.App.SaveConfig(cfg, true) + if err != nil { + c.Err = err + return + } + + c.LogAudit("updateConfig") + + cfg = c.App.GetSanitizedConfig() + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(cfg.ToJson())) +} + +func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { + format := r.URL.Query().Get("format") + + if format == "" { + c.Err = model.NewAppError("getClientConfig", "api.config.client.old_format.app_error", nil, "", http.StatusNotImplemented) + return + } + + if format != "old" { + c.SetInvalidParam("format") + return + } + + var config map[string]string + if len(c.App.Session.UserId) == 0 { + config = c.App.LimitedClientConfigWithComputed() + } else { + config = c.App.ClientConfigWithComputed() + } + + w.Write([]byte(model.MapToJson(config))) +} + +func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + envConfig := c.App.GetEnvironmentConfig() + + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(model.StringInterfaceToJson(envConfig))) +} diff --git a/api4/config_test.go b/api4/config_test.go new file mode 100644 index 00000000000..ddc5748e59d --- /dev/null +++ b/api4/config_test.go @@ -0,0 +1,326 @@ +package api4 + +import ( + "net/http" + "os" + "strings" + "testing" + + "github.com/mattermost/mattermost-server/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + _, resp := Client.GetConfig() + CheckForbiddenStatus(t, resp) + + cfg, resp := th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + require.NotEqual(t, "", cfg.TeamSettings.SiteName) + + if *cfg.LdapSettings.BindPassword != model.FAKE_SETTING && len(*cfg.LdapSettings.BindPassword) != 0 { + t.Fatal("did not sanitize properly") + } + if *cfg.FileSettings.PublicLinkSalt != model.FAKE_SETTING { + t.Fatal("did not sanitize properly") + } + if *cfg.FileSettings.AmazonS3SecretAccessKey != model.FAKE_SETTING && len(*cfg.FileSettings.AmazonS3SecretAccessKey) != 0 { + t.Fatal("did not sanitize properly") + } + if *cfg.EmailSettings.InviteSalt != model.FAKE_SETTING { + t.Fatal("did not sanitize properly") + } + if *cfg.EmailSettings.SMTPPassword != model.FAKE_SETTING && len(*cfg.EmailSettings.SMTPPassword) != 0 { + t.Fatal("did not sanitize properly") + } + if *cfg.GitLabSettings.Secret != model.FAKE_SETTING && len(*cfg.GitLabSettings.Secret) != 0 { + t.Fatal("did not sanitize properly") + } + if *cfg.SqlSettings.DataSource != model.FAKE_SETTING { + t.Fatal("did not sanitize properly") + } + if *cfg.SqlSettings.AtRestEncryptKey != model.FAKE_SETTING { + t.Fatal("did not sanitize properly") + } + if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceReplicas, " "), model.FAKE_SETTING) && len(cfg.SqlSettings.DataSourceReplicas) != 0 { + t.Fatal("did not sanitize properly") + } + if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceSearchReplicas, " "), model.FAKE_SETTING) && len(cfg.SqlSettings.DataSourceSearchReplicas) != 0 { + t.Fatal("did not sanitize properly") + } +} + +func TestReloadConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + t.Run("as system user", func(t *testing.T) { + ok, resp := Client.ReloadConfig() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should not Reload the config due no permission.") + } + }) + + t.Run("as system admin", func(t *testing.T) { + ok, resp := th.SystemAdminClient.ReloadConfig() + CheckNoError(t, resp) + if !ok { + t.Fatal("should Reload the config") + } + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + ok, resp := Client.ReloadConfig() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should not Reload the config due no permission.") + } + }) +} + +func TestUpdateConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + cfg, resp := th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + _, resp = Client.UpdateConfig(cfg) + CheckForbiddenStatus(t, resp) + + SiteName := th.App.Config().TeamSettings.SiteName + + *cfg.TeamSettings.SiteName = "MyFancyName" + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + require.Equal(t, "MyFancyName", *cfg.TeamSettings.SiteName, "It should update the SiteName") + + //Revert the change + cfg.TeamSettings.SiteName = SiteName + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + require.Equal(t, SiteName, cfg.TeamSettings.SiteName, "It should update the SiteName") + + t.Run("Should not be able to modify PluginSettings.EnableUploads", func(t *testing.T) { + oldEnableUploads := *th.App.Config().PluginSettings.EnableUploads + *cfg.PluginSettings.EnableUploads = !oldEnableUploads + + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads) + assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads) + + cfg.PluginSettings.EnableUploads = nil + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads) + assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads) + }) +} + +func TestUpdateConfigMessageExportSpecialHandling(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + messageExportEnabled := *th.App.Config().MessageExportSettings.EnableExport + messageExportTimestamp := *th.App.Config().MessageExportSettings.ExportFromTimestamp + + defer th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.MessageExportSettings.EnableExport = messageExportEnabled + *cfg.MessageExportSettings.ExportFromTimestamp = messageExportTimestamp + }) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.MessageExportSettings.EnableExport = false + *cfg.MessageExportSettings.ExportFromTimestamp = int64(0) + }) + + // Turn it on, timestamp should be updated. + cfg, resp := th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + *cfg.MessageExportSettings.EnableExport = true + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + assert.True(t, *th.App.Config().MessageExportSettings.EnableExport) + assert.NotEqual(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) + + // Turn it off, timestamp should be cleared. + cfg, resp = th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + *cfg.MessageExportSettings.EnableExport = false + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + assert.False(t, *th.App.Config().MessageExportSettings.EnableExport) + assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) + + // Set a value from the config file. + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.MessageExportSettings.EnableExport = false + *cfg.MessageExportSettings.ExportFromTimestamp = int64(12345) + }) + + // Turn it on, timestamp should *not* be updated. + cfg, resp = th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + *cfg.MessageExportSettings.EnableExport = true + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + assert.True(t, *th.App.Config().MessageExportSettings.EnableExport) + assert.Equal(t, int64(12345), *th.App.Config().MessageExportSettings.ExportFromTimestamp) + + // Turn it off, timestamp should be cleared. + cfg, resp = th.SystemAdminClient.GetConfig() + CheckNoError(t, resp) + + *cfg.MessageExportSettings.EnableExport = false + cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) + CheckNoError(t, resp) + + assert.False(t, *th.App.Config().MessageExportSettings.EnableExport) + assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) +} + +func TestGetEnvironmentConfig(t *testing.T) { + os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://example.mattermost.com") + os.Setenv("MM_SERVICESETTINGS_ENABLECUSTOMEMOJI", "true") + defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL") + + th := Setup().InitBasic() + defer th.TearDown() + + t.Run("as system admin", func(t *testing.T) { + SystemAdminClient := th.SystemAdminClient + + envConfig, resp := SystemAdminClient.GetEnvironmentConfig() + CheckNoError(t, resp) + + if serviceSettings, ok := envConfig["ServiceSettings"]; !ok { + t.Fatal("should've returned ServiceSettings") + } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { + t.Fatal("should've returned ServiceSettings as a map") + } else { + if siteURL, ok := serviceSettingsAsMap["SiteURL"]; !ok { + t.Fatal("should've returned ServiceSettings.SiteURL") + } else if siteURLAsBool, ok := siteURL.(bool); !ok { + t.Fatal("should've returned ServiceSettings.SiteURL as a boolean") + } else if !siteURLAsBool { + t.Fatal("should've returned ServiceSettings.SiteURL as true") + } + + if enableCustomEmoji, ok := serviceSettingsAsMap["EnableCustomEmoji"]; !ok { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji") + } else if enableCustomEmojiAsBool, ok := enableCustomEmoji.(bool); !ok { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as a boolean") + } else if !enableCustomEmojiAsBool { + t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as true") + } + } + + if _, ok := envConfig["TeamSettings"]; ok { + t.Fatal("should not have returned TeamSettings") + } + }) + + t.Run("as team admin", func(t *testing.T) { + TeamAdminClient := th.CreateClient() + th.LoginTeamAdminWithClient(TeamAdminClient) + + _, resp := TeamAdminClient.GetEnvironmentConfig() + CheckForbiddenStatus(t, resp) + }) + + t.Run("as regular user", func(t *testing.T) { + Client := th.Client + + _, resp := Client.GetEnvironmentConfig() + CheckForbiddenStatus(t, resp) + }) + + t.Run("as not-regular user", func(t *testing.T) { + Client := th.CreateClient() + + _, resp := Client.GetEnvironmentConfig() + CheckUnauthorizedStatus(t, resp) + }) +} + +func TestGetOldClientConfig(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + + testKey := "supersecretkey" + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.GoogleDeveloperKey = testKey }) + + t.Run("with session", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.GoogleDeveloperKey = testKey + }) + + Client := th.Client + + config, resp := Client.GetOldClientConfig("") + CheckNoError(t, resp) + + if len(config["Version"]) == 0 { + t.Fatal("config not returned correctly") + } + + if config["GoogleDeveloperKey"] != testKey { + t.Fatal("config missing developer key") + } + }) + + t.Run("without session", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.GoogleDeveloperKey = testKey + }) + + Client := th.CreateClient() + + config, resp := Client.GetOldClientConfig("") + CheckNoError(t, resp) + + if len(config["Version"]) == 0 { + t.Fatal("config not returned correctly") + } + + if _, ok := config["GoogleDeveloperKey"]; ok { + t.Fatal("config should be missing developer key") + } + }) + + t.Run("missing format", func(t *testing.T) { + Client := th.Client + + if _, err := Client.DoApiGet("/config/client", ""); err == nil || err.StatusCode != http.StatusNotImplemented { + t.Fatal("should have errored with 501") + } + }) + + t.Run("invalid format", func(t *testing.T) { + Client := th.Client + + if _, err := Client.DoApiGet("/config/client?format=junk", ""); err == nil || err.StatusCode != http.StatusBadRequest { + t.Fatal("should have errored with 400") + } + }) +} diff --git a/api4/elasticsearch.go b/api4/elasticsearch.go index fd3874b3837..1e0a3a90689 100644 --- a/api4/elasticsearch.go +++ b/api4/elasticsearch.go @@ -25,6 +25,11 @@ func testElasticsearch(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("testElasticsearch", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + if err := c.App.TestElasticsearch(cfg); err != nil { c.Err = err return @@ -39,6 +44,11 @@ func purgeElasticsearchIndexes(c *Context, w http.ResponseWriter, r *http.Reques return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("purgeElasticsearchIndexes", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + if err := c.App.PurgeElasticsearchIndexes(); err != nil { c.Err = err return diff --git a/api4/elasticsearch_test.go b/api4/elasticsearch_test.go index c1037303f71..db005015b0d 100644 --- a/api4/elasticsearch_test.go +++ b/api4/elasticsearch_test.go @@ -5,26 +5,50 @@ package api4 import ( "testing" + + "github.com/mattermost/mattermost-server/model" ) func TestElasticsearchTest(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() - _, resp := th.Client.TestElasticsearch() - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := th.Client.TestElasticsearch() + CheckForbiddenStatus(t, resp) + }) - _, resp = th.SystemAdminClient.TestElasticsearch() - CheckNotImplementedStatus(t, resp) + t.Run("as system admin", func(t *testing.T) { + _, resp := th.SystemAdminClient.TestElasticsearch() + CheckNotImplementedStatus(t, resp) + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.TestElasticsearch() + CheckForbiddenStatus(t, resp) + }) } func TestElasticsearchPurgeIndexes(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() - _, resp := th.Client.PurgeElasticsearchIndexes() - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := th.Client.PurgeElasticsearchIndexes() + CheckForbiddenStatus(t, resp) + }) - _, resp = th.SystemAdminClient.PurgeElasticsearchIndexes() - CheckNotImplementedStatus(t, resp) + t.Run("as system admin", func(t *testing.T) { + _, resp := th.SystemAdminClient.PurgeElasticsearchIndexes() + CheckNotImplementedStatus(t, resp) + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.PurgeElasticsearchIndexes() + CheckForbiddenStatus(t, resp) + }) } diff --git a/api4/license.go b/api4/license.go new file mode 100644 index 00000000000..b21bd9a9802 --- /dev/null +++ b/api4/license.go @@ -0,0 +1,131 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package api4 + +import ( + "bytes" + "io" + "net/http" + + "github.com/mattermost/mattermost-server/model" +) + +func (api *API) InitLicense() { + api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(addLicense)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(removeLicense)).Methods("DELETE") + api.BaseRoutes.ApiRoot.Handle("/license/client", api.ApiHandler(getClientLicense)).Methods("GET") +} + +func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { + format := r.URL.Query().Get("format") + + if format == "" { + c.Err = model.NewAppError("getClientLicense", "api.license.client.old_format.app_error", nil, "", http.StatusNotImplemented) + return + } + + if format != "old" { + c.SetInvalidParam("format") + return + } + + etag := c.App.GetClientLicenseEtag(true) + if c.HandleEtag(etag, "Get Client License", w, r) { + return + } + + var clientLicense map[string]string + + if c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + clientLicense = c.App.ClientLicense() + } else { + clientLicense = c.App.GetSanitizedClientLicense() + } + + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + w.Write([]byte(model.MapToJson(clientLicense))) +} + +func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("attempt") + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("addLicense", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + + err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + m := r.MultipartForm + + fileArray, ok := m.File["license"] + if !ok { + c.Err = model.NewAppError("addLicense", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest) + return + } + + if len(fileArray) <= 0 { + c.Err = model.NewAppError("addLicense", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest) + return + } + + fileData := fileArray[0] + + file, err := fileData.Open() + if err != nil { + c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + buf := bytes.NewBuffer(nil) + io.Copy(buf, file) + + license, appErr := c.App.SaveLicense(buf.Bytes()) + if appErr != nil { + if appErr.Id == model.EXPIRED_LICENSE_ERROR { + c.LogAudit("failed - expired or non-started license") + } else if appErr.Id == model.INVALID_LICENSE_ERROR { + c.LogAudit("failed - invalid license") + } else { + c.LogAudit("failed - unable to save license") + } + c.Err = appErr + return + } + + c.LogAudit("success") + w.Write([]byte(license.ToJson())) +} + +func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { + c.LogAudit("attempt") + + if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("removeLicense", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + + if err := c.App.RemoveLicense(); err != nil { + c.Err = err + return + } + + c.LogAudit("success") + ReturnStatusOK(w) +} diff --git a/api4/license_test.go b/api4/license_test.go new file mode 100644 index 00000000000..a71b0ae72e2 --- /dev/null +++ b/api4/license_test.go @@ -0,0 +1,105 @@ +package api4 + +import ( + "net/http" + "testing" + + "github.com/mattermost/mattermost-server/model" +) + +func TestGetOldClientLicense(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + license, resp := Client.GetOldClientLicense("") + CheckNoError(t, resp) + + if len(license["IsLicensed"]) == 0 { + t.Fatal("license not returned correctly") + } + + Client.Logout() + + _, resp = Client.GetOldClientLicense("") + CheckNoError(t, resp) + + if _, err := Client.DoApiGet("/license/client", ""); err == nil || err.StatusCode != http.StatusNotImplemented { + t.Fatal("should have errored with 501") + } + + if _, err := Client.DoApiGet("/license/client?format=junk", ""); err == nil || err.StatusCode != http.StatusBadRequest { + t.Fatal("should have errored with 400") + } + + license, resp = th.SystemAdminClient.GetOldClientLicense("") + CheckNoError(t, resp) + + if len(license["IsLicensed"]) == 0 { + t.Fatal("license not returned correctly") + } +} + +func TestUploadLicenseFile(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + t.Run("as system user", func(t *testing.T) { + ok, resp := Client.UploadLicenseFile([]byte{}) + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should fail") + } + }) + + t.Run("as system admin user", func(t *testing.T) { + ok, resp := th.SystemAdminClient.UploadLicenseFile([]byte{}) + CheckBadRequestStatus(t, resp) + if ok { + t.Fatal("should fail") + } + }) + + t.Run("as restricted system admin user", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + ok, resp := th.SystemAdminClient.UploadLicenseFile([]byte{}) + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should fail") + } + }) +} + +func TestRemoveLicenseFile(t *testing.T) { + th := Setup().InitBasic() + defer th.TearDown() + Client := th.Client + + t.Run("as system user", func(t *testing.T) { + ok, resp := Client.RemoveLicenseFile() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should fail") + } + }) + + t.Run("as system admin user", func(t *testing.T) { + ok, resp := th.SystemAdminClient.RemoveLicenseFile() + CheckNoError(t, resp) + if !ok { + t.Fatal("should pass") + } + }) + + t.Run("as restricted system admin user", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + ok, resp := th.SystemAdminClient.RemoveLicenseFile() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should fail") + } + }) +} diff --git a/api4/system.go b/api4/system.go index ac868d360e9..7f0c2bde2d8 100644 --- a/api4/system.go +++ b/api4/system.go @@ -4,10 +4,8 @@ package api4 import ( - "bytes" "encoding/json" "fmt" - "io" "net/http" "runtime" @@ -26,16 +24,6 @@ func (api *API) InitSystem() { api.BaseRoutes.System.Handle("/timezones", api.ApiSessionRequired(getSupportedTimezones)).Methods("GET") - 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/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") - - api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(addLicense)).Methods("POST") - api.BaseRoutes.ApiRoot.Handle("/license", api.ApiSessionRequired(removeLicense)).Methods("DELETE") - api.BaseRoutes.ApiRoot.Handle("/license/client", api.ApiHandler(getClientLicense)).Methods("GET") - api.BaseRoutes.ApiRoot.Handle("/audits", api.ApiSessionRequired(getAudits)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/email/test", api.ApiSessionRequired(testEmail)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/file/s3_test", api.ApiSessionRequired(testS3)).Methods("POST") @@ -88,6 +76,11 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("testEmail", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + err := c.App.TestEmail(c.App.Session.UserId, cfg) if err != nil { c.Err = err @@ -97,73 +90,6 @@ func testEmail(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } -func getConfig(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) - return - } - - cfg := c.App.GetSanitizedConfig() - - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Write([]byte(cfg.ToJson())) -} - -func configReload(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) - return - } - - c.App.ReloadConfig() - - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - ReturnStatusOK(w) -} - -func updateConfig(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 - } - - // Do not allow plugin uploads to be toggled through the API - cfg.PluginSettings.EnableUploads = c.App.Config().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 != *c.App.Config().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) - } - } - - err := c.App.SaveConfig(cfg, true) - if err != nil { - c.Err = err - return - } - - c.LogAudit("updateConfig") - - cfg = c.App.GetSanitizedConfig() - - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Write([]byte(cfg.ToJson())) -} - func getAudits(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) @@ -181,12 +107,16 @@ func getAudits(c *Context, w http.ResponseWriter, r *http.Request) { } func databaseRecycle(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("databaseRecycle", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + c.App.RecycleDatabaseConnection() ReturnStatusOK(w) @@ -198,6 +128,11 @@ func invalidateCaches(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("invalidateCaches", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + err := c.App.InvalidateAllCaches() if err != nil { c.Err = err @@ -259,144 +194,6 @@ func postLog(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(m))) } -func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { - format := r.URL.Query().Get("format") - - if format == "" { - c.Err = model.NewAppError("getClientConfig", "api.config.client.old_format.app_error", nil, "", http.StatusNotImplemented) - return - } - - if format != "old" { - c.SetInvalidParam("format") - return - } - - var config map[string]string - if len(c.App.Session.UserId) == 0 { - config = c.App.LimitedClientConfigWithComputed() - } else { - config = c.App.ClientConfigWithComputed() - } - - w.Write([]byte(model.MapToJson(config))) -} - -func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) { - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) - return - } - - envConfig := c.App.GetEnvironmentConfig() - - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Write([]byte(model.StringInterfaceToJson(envConfig))) -} - -func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { - format := r.URL.Query().Get("format") - - if format == "" { - c.Err = model.NewAppError("getClientLicense", "api.license.client.old_format.app_error", nil, "", http.StatusNotImplemented) - return - } - - if format != "old" { - c.SetInvalidParam("format") - return - } - - etag := c.App.GetClientLicenseEtag(true) - if c.HandleEtag(etag, "Get Client License", w, r) { - return - } - - var clientLicense map[string]string - - if c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - clientLicense = c.App.ClientLicense() - } else { - clientLicense = c.App.GetSanitizedClientLicense() - } - - w.Header().Set(model.HEADER_ETAG_SERVER, etag) - w.Write([]byte(model.MapToJson(clientLicense))) -} - -func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { - c.LogAudit("attempt") - - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) - return - } - - err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - m := r.MultipartForm - - fileArray, ok := m.File["license"] - if !ok { - c.Err = model.NewAppError("addLicense", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest) - return - } - - if len(fileArray) <= 0 { - c.Err = model.NewAppError("addLicense", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest) - return - } - - fileData := fileArray[0] - - file, err := fileData.Open() - if err != nil { - c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, err.Error(), http.StatusBadRequest) - return - } - defer file.Close() - - buf := bytes.NewBuffer(nil) - io.Copy(buf, file) - - license, appErr := c.App.SaveLicense(buf.Bytes()) - if appErr != nil { - if appErr.Id == model.EXPIRED_LICENSE_ERROR { - c.LogAudit("failed - expired or non-started license") - } else if appErr.Id == model.INVALID_LICENSE_ERROR { - c.LogAudit("failed - invalid license") - } else { - c.LogAudit("failed - unable to save license") - } - c.Err = appErr - return - } - - c.LogAudit("success") - w.Write([]byte(license.ToJson())) -} - -func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { - c.LogAudit("attempt") - - if !c.App.SessionHasPermissionTo(c.App.Session, model.PERMISSION_MANAGE_SYSTEM) { - c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) - return - } - - if err := c.App.RemoveLicense(); err != nil { - c.Err = err - return - } - - c.LogAudit("success") - ReturnStatusOK(w) -} - func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") teamId := r.URL.Query().Get("team_id") @@ -450,6 +247,11 @@ func testS3(c *Context, w http.ResponseWriter, r *http.Request) { return } + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("testS3", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + err := filesstore.CheckMandatoryS3Fields(&cfg.FileSettings) if err != nil { c.Err = err diff --git a/api4/system_test.go b/api4/system_test.go index 314f3b3a280..6047c941d47 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -5,13 +5,11 @@ import ( "net/http" "net/http/httptest" "os" - "strings" "testing" "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestGetPing(t *testing.T) { @@ -38,342 +36,6 @@ func TestGetPing(t *testing.T) { } } -func TestGetConfig(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - _, resp := Client.GetConfig() - CheckForbiddenStatus(t, resp) - - cfg, resp := th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - require.NotEqual(t, "", cfg.TeamSettings.SiteName) - - if *cfg.LdapSettings.BindPassword != model.FAKE_SETTING && len(*cfg.LdapSettings.BindPassword) != 0 { - t.Fatal("did not sanitize properly") - } - if *cfg.FileSettings.PublicLinkSalt != model.FAKE_SETTING { - t.Fatal("did not sanitize properly") - } - if *cfg.FileSettings.AmazonS3SecretAccessKey != model.FAKE_SETTING && len(*cfg.FileSettings.AmazonS3SecretAccessKey) != 0 { - t.Fatal("did not sanitize properly") - } - if *cfg.EmailSettings.InviteSalt != model.FAKE_SETTING { - t.Fatal("did not sanitize properly") - } - if *cfg.EmailSettings.SMTPPassword != model.FAKE_SETTING && len(*cfg.EmailSettings.SMTPPassword) != 0 { - t.Fatal("did not sanitize properly") - } - if *cfg.GitLabSettings.Secret != model.FAKE_SETTING && len(*cfg.GitLabSettings.Secret) != 0 { - t.Fatal("did not sanitize properly") - } - if *cfg.SqlSettings.DataSource != model.FAKE_SETTING { - t.Fatal("did not sanitize properly") - } - if *cfg.SqlSettings.AtRestEncryptKey != model.FAKE_SETTING { - t.Fatal("did not sanitize properly") - } - if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceReplicas, " "), model.FAKE_SETTING) && len(cfg.SqlSettings.DataSourceReplicas) != 0 { - t.Fatal("did not sanitize properly") - } - if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceSearchReplicas, " "), model.FAKE_SETTING) && len(cfg.SqlSettings.DataSourceSearchReplicas) != 0 { - t.Fatal("did not sanitize properly") - } -} - -func TestReloadConfig(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - flag, resp := Client.ReloadConfig() - CheckForbiddenStatus(t, resp) - if flag { - t.Fatal("should not Reload the config due no permission.") - } - - flag, resp = th.SystemAdminClient.ReloadConfig() - CheckNoError(t, resp) - if !flag { - t.Fatal("should Reload the config") - } - - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 }) - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.EnableOpenServer = true }) -} - -func TestUpdateConfig(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - cfg, resp := th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - _, resp = Client.UpdateConfig(cfg) - CheckForbiddenStatus(t, resp) - - SiteName := th.App.Config().TeamSettings.SiteName - - *cfg.TeamSettings.SiteName = "MyFancyName" - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - require.Equal(t, "MyFancyName", *cfg.TeamSettings.SiteName, "It should update the SiteName") - - //Revert the change - cfg.TeamSettings.SiteName = SiteName - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - require.Equal(t, SiteName, cfg.TeamSettings.SiteName, "It should update the SiteName") - - t.Run("Should not be able to modify PluginSettings.EnableUploads", func(t *testing.T) { - oldEnableUploads := *th.App.Config().PluginSettings.EnableUploads - *cfg.PluginSettings.EnableUploads = !oldEnableUploads - - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads) - assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads) - - cfg.PluginSettings.EnableUploads = nil - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads) - assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads) - }) -} - -func TestUpdateConfigMessageExportSpecialHandling(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - - messageExportEnabled := *th.App.Config().MessageExportSettings.EnableExport - messageExportTimestamp := *th.App.Config().MessageExportSettings.ExportFromTimestamp - - defer th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.MessageExportSettings.EnableExport = messageExportEnabled - *cfg.MessageExportSettings.ExportFromTimestamp = messageExportTimestamp - }) - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.MessageExportSettings.EnableExport = false - *cfg.MessageExportSettings.ExportFromTimestamp = int64(0) - }) - - // Turn it on, timestamp should be updated. - cfg, resp := th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - *cfg.MessageExportSettings.EnableExport = true - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - assert.True(t, *th.App.Config().MessageExportSettings.EnableExport) - assert.NotEqual(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) - - // Turn it off, timestamp should be cleared. - cfg, resp = th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - *cfg.MessageExportSettings.EnableExport = false - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - assert.False(t, *th.App.Config().MessageExportSettings.EnableExport) - assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) - - // Set a value from the config file. - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.MessageExportSettings.EnableExport = false - *cfg.MessageExportSettings.ExportFromTimestamp = int64(12345) - }) - - // Turn it on, timestamp should *not* be updated. - cfg, resp = th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - *cfg.MessageExportSettings.EnableExport = true - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - assert.True(t, *th.App.Config().MessageExportSettings.EnableExport) - assert.Equal(t, int64(12345), *th.App.Config().MessageExportSettings.ExportFromTimestamp) - - // Turn it off, timestamp should be cleared. - cfg, resp = th.SystemAdminClient.GetConfig() - CheckNoError(t, resp) - - *cfg.MessageExportSettings.EnableExport = false - cfg, resp = th.SystemAdminClient.UpdateConfig(cfg) - CheckNoError(t, resp) - - assert.False(t, *th.App.Config().MessageExportSettings.EnableExport) - assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp) -} - -func TestGetEnvironmentConfig(t *testing.T) { - os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://example.mattermost.com") - os.Setenv("MM_SERVICESETTINGS_ENABLECUSTOMEMOJI", "true") - defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL") - - th := Setup().InitBasic() - defer th.TearDown() - - t.Run("as system admin", func(t *testing.T) { - SystemAdminClient := th.SystemAdminClient - - envConfig, resp := SystemAdminClient.GetEnvironmentConfig() - CheckNoError(t, resp) - - if serviceSettings, ok := envConfig["ServiceSettings"]; !ok { - t.Fatal("should've returned ServiceSettings") - } else if serviceSettingsAsMap, ok := serviceSettings.(map[string]interface{}); !ok { - t.Fatal("should've returned ServiceSettings as a map") - } else { - if siteURL, ok := serviceSettingsAsMap["SiteURL"]; !ok { - t.Fatal("should've returned ServiceSettings.SiteURL") - } else if siteURLAsBool, ok := siteURL.(bool); !ok { - t.Fatal("should've returned ServiceSettings.SiteURL as a boolean") - } else if !siteURLAsBool { - t.Fatal("should've returned ServiceSettings.SiteURL as true") - } - - if enableCustomEmoji, ok := serviceSettingsAsMap["EnableCustomEmoji"]; !ok { - t.Fatal("should've returned ServiceSettings.EnableCustomEmoji") - } else if enableCustomEmojiAsBool, ok := enableCustomEmoji.(bool); !ok { - t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as a boolean") - } else if !enableCustomEmojiAsBool { - t.Fatal("should've returned ServiceSettings.EnableCustomEmoji as true") - } - } - - if _, ok := envConfig["TeamSettings"]; ok { - t.Fatal("should not have returned TeamSettings") - } - }) - - t.Run("as team admin", func(t *testing.T) { - TeamAdminClient := th.CreateClient() - th.LoginTeamAdminWithClient(TeamAdminClient) - - _, resp := TeamAdminClient.GetEnvironmentConfig() - CheckForbiddenStatus(t, resp) - }) - - t.Run("as regular user", func(t *testing.T) { - Client := th.Client - - _, resp := Client.GetEnvironmentConfig() - CheckForbiddenStatus(t, resp) - }) - - t.Run("as not-regular user", func(t *testing.T) { - Client := th.CreateClient() - - _, resp := Client.GetEnvironmentConfig() - CheckUnauthorizedStatus(t, resp) - }) -} - -func TestGetOldClientConfig(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - - testKey := "supersecretkey" - th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.GoogleDeveloperKey = testKey }) - - t.Run("with session", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.GoogleDeveloperKey = testKey - }) - - Client := th.Client - - config, resp := Client.GetOldClientConfig("") - CheckNoError(t, resp) - - if len(config["Version"]) == 0 { - t.Fatal("config not returned correctly") - } - - if config["GoogleDeveloperKey"] != testKey { - t.Fatal("config missing developer key") - } - }) - - t.Run("without session", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.GoogleDeveloperKey = testKey - }) - - Client := th.CreateClient() - - config, resp := Client.GetOldClientConfig("") - CheckNoError(t, resp) - - if len(config["Version"]) == 0 { - t.Fatal("config not returned correctly") - } - - if _, ok := config["GoogleDeveloperKey"]; ok { - t.Fatal("config should be missing developer key") - } - }) - - t.Run("missing format", func(t *testing.T) { - Client := th.Client - - if _, err := Client.DoApiGet("/config/client", ""); err == nil || err.StatusCode != http.StatusNotImplemented { - t.Fatal("should have errored with 501") - } - }) - - t.Run("invalid format", func(t *testing.T) { - Client := th.Client - - if _, err := Client.DoApiGet("/config/client?format=junk", ""); err == nil || err.StatusCode != http.StatusBadRequest { - t.Fatal("should have errored with 400") - } - }) -} - -func TestGetOldClientLicense(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - license, resp := Client.GetOldClientLicense("") - CheckNoError(t, resp) - - if len(license["IsLicensed"]) == 0 { - t.Fatal("license not returned correctly") - } - - Client.Logout() - - _, resp = Client.GetOldClientLicense("") - CheckNoError(t, resp) - - if _, err := Client.DoApiGet("/license/client", ""); err == nil || err.StatusCode != http.StatusNotImplemented { - t.Fatal("should have errored with 501") - } - - if _, err := Client.DoApiGet("/license/client?format=junk", ""); err == nil || err.StatusCode != http.StatusBadRequest { - t.Fatal("should have errored with 400") - } - - license, resp = th.SystemAdminClient.GetOldClientLicense("") - CheckNoError(t, resp) - - if len(license["IsLicensed"]) == 0 { - t.Fatal("license not returned correctly") - } -} - func TestGetAudits(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -431,27 +93,38 @@ func TestEmailTest(t *testing.T) { }, } - _, resp := Client.TestEmail(&config) - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := Client.TestEmail(&config) + CheckForbiddenStatus(t, resp) + }) - _, resp = th.SystemAdminClient.TestEmail(&config) - CheckErrorMessage(t, resp, "api.admin.test_email.missing_server") - CheckBadRequestStatus(t, resp) + t.Run("as system admin", func(t *testing.T) { + _, resp := th.SystemAdminClient.TestEmail(&config) + CheckErrorMessage(t, resp, "api.admin.test_email.missing_server") + CheckBadRequestStatus(t, resp) - inbucket_host := os.Getenv("CI_INBUCKET_HOST") - if inbucket_host == "" { - inbucket_host = "dockerhost" - } + inbucket_host := os.Getenv("CI_INBUCKET_HOST") + if inbucket_host == "" { + inbucket_host = "dockerhost" + } - inbucket_port := os.Getenv("CI_INBUCKET_PORT") - if inbucket_port == "" { - inbucket_port = "9000" - } + inbucket_port := os.Getenv("CI_INBUCKET_PORT") + if inbucket_port == "" { + inbucket_port = "9000" + } - *config.EmailSettings.SMTPServer = inbucket_host - *config.EmailSettings.SMTPPort = inbucket_port - _, resp = th.SystemAdminClient.TestEmail(&config) - CheckOKStatus(t, resp) + *config.EmailSettings.SMTPServer = inbucket_host + *config.EmailSettings.SMTPPort = inbucket_port + _, resp = th.SystemAdminClient.TestEmail(&config) + CheckOKStatus(t, resp) + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.TestEmail(&config) + CheckForbiddenStatus(t, resp) + }) } func TestDatabaseRecycle(t *testing.T) { @@ -459,11 +132,22 @@ func TestDatabaseRecycle(t *testing.T) { defer th.TearDown() Client := th.Client - _, resp := Client.DatabaseRecycle() - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := Client.DatabaseRecycle() + CheckForbiddenStatus(t, resp) + }) - _, resp = th.SystemAdminClient.DatabaseRecycle() - CheckNoError(t, resp) + t.Run("as system admin", func(t *testing.T) { + _, resp := th.SystemAdminClient.DatabaseRecycle() + CheckNoError(t, resp) + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.DatabaseRecycle() + CheckForbiddenStatus(t, resp) + }) } func TestInvalidateCaches(t *testing.T) { @@ -471,17 +155,31 @@ func TestInvalidateCaches(t *testing.T) { defer th.TearDown() Client := th.Client - flag, resp := Client.InvalidateCaches() - CheckForbiddenStatus(t, resp) - if flag { - t.Fatal("should not clean the cache due no permission.") - } + t.Run("as system user", func(t *testing.T) { + ok, resp := Client.InvalidateCaches() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should not clean the cache due no permission.") + } + }) - flag, resp = th.SystemAdminClient.InvalidateCaches() - CheckNoError(t, resp) - if !flag { - t.Fatal("should clean the cache") - } + t.Run("as system admin", func(t *testing.T) { + ok, resp := th.SystemAdminClient.InvalidateCaches() + CheckNoError(t, resp) + if !ok { + t.Fatal("should clean the cache") + } + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + ok, resp := th.SystemAdminClient.InvalidateCaches() + CheckForbiddenStatus(t, resp) + if ok { + t.Fatal("should not clean the cache due no permission.") + } + }) } func TestGetLogs(t *testing.T) { @@ -559,42 +257,6 @@ func TestPostLog(t *testing.T) { } } -func TestUploadLicenseFile(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - ok, resp := Client.UploadLicenseFile([]byte{}) - CheckForbiddenStatus(t, resp) - if ok { - t.Fatal("should fail") - } - - ok, resp = th.SystemAdminClient.UploadLicenseFile([]byte{}) - CheckBadRequestStatus(t, resp) - if ok { - t.Fatal("should fail") - } -} - -func TestRemoveLicenseFile(t *testing.T) { - th := Setup().InitBasic() - defer th.TearDown() - Client := th.Client - - ok, resp := Client.RemoveLicenseFile() - CheckForbiddenStatus(t, resp) - if ok { - t.Fatal("should fail") - } - - ok, resp = th.SystemAdminClient.RemoveLicenseFile() - CheckNoError(t, resp) - if !ok { - t.Fatal("should pass") - } -} - func TestGetAnalyticsOld(t *testing.T) { th := Setup().InitBasic() defer th.TearDown() @@ -696,34 +358,45 @@ func TestS3TestConnection(t *testing.T) { }, } - _, resp := Client.TestS3Connection(&config) - CheckForbiddenStatus(t, resp) + t.Run("as system user", func(t *testing.T) { + _, resp := Client.TestS3Connection(&config) + CheckForbiddenStatus(t, resp) + }) - _, resp = th.SystemAdminClient.TestS3Connection(&config) - CheckBadRequestStatus(t, resp) - if resp.Error.Message != "S3 Bucket is required" { - t.Fatal("should return error - missing s3 bucket") - } + t.Run("as system admin", func(t *testing.T) { + _, resp := th.SystemAdminClient.TestS3Connection(&config) + CheckBadRequestStatus(t, resp) + if resp.Error.Message != "S3 Bucket is required" { + t.Fatal("should return error - missing s3 bucket") + } - // If this fails, check the test configuration to ensure minio is setup with the - // `mattermost-test` bucket defined by model.MINIO_BUCKET. - *config.FileSettings.AmazonS3Bucket = model.MINIO_BUCKET - *config.FileSettings.AmazonS3Region = "us-east-1" - _, resp = th.SystemAdminClient.TestS3Connection(&config) - CheckOKStatus(t, resp) + // If this fails, check the test configuration to ensure minio is setup with the + // `mattermost-test` bucket defined by model.MINIO_BUCKET. + *config.FileSettings.AmazonS3Bucket = model.MINIO_BUCKET + *config.FileSettings.AmazonS3Region = "us-east-1" + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckOKStatus(t, resp) - config.FileSettings.AmazonS3Region = model.NewString("") - _, resp = th.SystemAdminClient.TestS3Connection(&config) - CheckOKStatus(t, resp) + config.FileSettings.AmazonS3Region = model.NewString("") + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckOKStatus(t, resp) - config.FileSettings.AmazonS3Bucket = model.NewString("Wrong_bucket") - _, resp = th.SystemAdminClient.TestS3Connection(&config) - CheckInternalErrorStatus(t, resp) - assert.Equal(t, "Unable to create bucket.", resp.Error.Message) + config.FileSettings.AmazonS3Bucket = model.NewString("Wrong_bucket") + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckInternalErrorStatus(t, resp) + assert.Equal(t, "Unable to create bucket.", resp.Error.Message) - *config.FileSettings.AmazonS3Bucket = "shouldcreatenewbucket" - _, resp = th.SystemAdminClient.TestS3Connection(&config) - CheckOKStatus(t, resp) + *config.FileSettings.AmazonS3Bucket = "shouldcreatenewbucket" + _, resp = th.SystemAdminClient.TestS3Connection(&config) + CheckOKStatus(t, resp) + }) + + t.Run("as restricted system admin", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + + _, resp := th.SystemAdminClient.TestS3Connection(&config) + CheckForbiddenStatus(t, resp) + }) } diff --git a/app/admin.go b/app/admin.go index 7d54548f623..8f47864763b 100644 --- a/app/admin.go +++ b/app/admin.go @@ -12,12 +12,10 @@ import ( "net/http" - "github.com/mattermost/mattermost-server/config" "github.com/mattermost/mattermost-server/mlog" "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/services/mailservice" "github.com/mattermost/mattermost-server/utils" - "github.com/pkg/errors" ) func (a *App) GetLogs(page, perPage int) ([]string, *model.AppError) { @@ -150,43 +148,6 @@ func (a *App) InvalidateAllCachesSkipSend() { a.LoadLicense() } -func (a *App) GetSanitizedConfig() *model.Config { - cfg := a.Config().Clone() - cfg.Sanitize() - - return cfg -} - -func (a *App) GetEnvironmentConfig() map[string]interface{} { - return a.EnvironmentConfig() -} - -func (a *App) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) *model.AppError { - oldCfg, err := a.Srv.configStore.Set(newCfg) - if errors.Cause(err) == config.ErrReadOnlyConfiguration { - return model.NewAppError("saveConfig", "ent.cluster.save_config.error", nil, err.Error(), http.StatusForbidden) - } else if err != nil { - return model.NewAppError("saveConfig", "app.save_config.app_error", nil, err.Error(), http.StatusInternalServerError) - } - - if a.Metrics != nil { - if *a.Config().MetricsSettings.Enable { - a.Metrics.StartServer() - } else { - a.Metrics.StopServer() - } - } - - if a.Cluster != nil { - err := a.Cluster.ConfigChanged(oldCfg, newCfg, sendConfigChangeClusterMessage) - if err != nil { - return err - } - } - - return nil -} - func (a *App) RecycleDatabaseConnection() { oldStore := a.Srv.Store diff --git a/app/config.go b/app/config.go index 8d614228846..f65911eec5f 100644 --- a/app/config.go +++ b/app/config.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "net/url" "runtime/debug" "strconv" @@ -358,3 +359,43 @@ func (a *App) GetConfigFile(name string) ([]byte, error) { return data, nil } + +// GetSanitizedConfig gets the configuration for a system admin without any secrets. +func (a *App) GetSanitizedConfig() *model.Config { + cfg := a.Config().Clone() + cfg.Sanitize() + + return cfg +} + +// GetEnvironmentConfig returns a map of configuration keys whose values have been overridden by an environment variable. +func (a *App) GetEnvironmentConfig() map[string]interface{} { + return a.EnvironmentConfig() +} + +// SaveConfig replaces the active configuration, optionally notifying cluster peers. +func (a *App) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) *model.AppError { + oldCfg, err := a.Srv.configStore.Set(newCfg) + if errors.Cause(err) == config.ErrReadOnlyConfiguration { + return model.NewAppError("saveConfig", "ent.cluster.save_config.error", nil, err.Error(), http.StatusForbidden) + } else if err != nil { + return model.NewAppError("saveConfig", "app.save_config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + if a.Metrics != nil { + if *a.Config().MetricsSettings.Enable { + a.Metrics.StartServer() + } else { + a.Metrics.StopServer() + } + } + + if a.Cluster != nil { + err := a.Cluster.ConfigChanged(oldCfg, newCfg, sendConfigChangeClusterMessage) + if err != nil { + return err + } + } + + return nil +} diff --git a/config/default.json b/config/default.json index 0dbee3e4f82..755270f3cb6 100644 --- a/config/default.json +++ b/config/default.json @@ -359,7 +359,8 @@ "ClientSideCertEnable": false, "ClientSideCertCheck": "secondary", "DisablePostMetadata": false, - "LinkMetadataTimeoutMilliseconds": 5000 + "LinkMetadataTimeoutMilliseconds": 5000, + "RestrictSystemAdmin": false }, "AnalyticsSettings": { "MaxUsersForStatistics": 2500 diff --git a/i18n/en.json b/i18n/en.json index 36c75567be7..ad90a80fc8f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -55,6 +55,10 @@ "id": "api.admin.add_certificate.array.app_error", "translation": "No file under 'certificate' in request." }, + { + "id": "api.restricted_system_admin", + "translation": "This action is forbidden to a restricted system admin." + }, { "id": "app.submit_interactive_dialog.json_error", "translation": "Encountered an error encoding JSON for the interactive dialog." diff --git a/model/config.go b/model/config.go index e081ad93a53..92b380e5f6e 100644 --- a/model/config.go +++ b/model/config.go @@ -713,6 +713,7 @@ type ExperimentalSettings struct { ClientSideCertCheck *string DisablePostMetadata *bool LinkMetadataTimeoutMilliseconds *int64 + RestrictSystemAdmin *bool } func (s *ExperimentalSettings) SetDefaults() { @@ -731,6 +732,10 @@ func (s *ExperimentalSettings) SetDefaults() { if s.LinkMetadataTimeoutMilliseconds == nil { s.LinkMetadataTimeoutMilliseconds = NewInt64(EXPERIMENTAL_SETTINGS_DEFAULT_LINK_METADATA_TIMEOUT_MILLISECONDS) } + + if s.RestrictSystemAdmin == nil { + s.RestrictSystemAdmin = NewBool(false) + } } type AnalyticsSettings struct {