diff --git a/api4/team.go b/api4/team.go index 5a337cae5e..df376545f6 100644 --- a/api4/team.go +++ b/api4/team.go @@ -95,6 +95,29 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) { return } + // Freemium enabled, on a cloud license. We must check limits before allowing to create + if c.App.Config().FeatureFlags != nil && c.App.Config().FeatureFlags.CloudFree && (c.App.Channels().License() != nil && c.App.Channels().License().Features != nil && *c.App.Channels().License().Features.Cloud) { + limits, err := c.App.Cloud().GetCloudLimits(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.createTeam", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + // If there are no limits for teams, for active teams, or the limit for active teams is less than 0, do nothing + if !(limits == nil || limits.Teams == nil || limits.Teams.Active == nil || *limits.Teams.Active <= 0) { + teamsUsage, appErr := c.App.GetTeamsUsage() + if appErr != nil { + c.Err = appErr + return + } + // if the number of active teams is greater than or equal to the limit, return 400 + if teamsUsage.Active >= int64(*limits.Teams.Active) { + c.Err = model.NewAppError("Api4.createTeam", "api.cloud.teams_limit_reached.create", nil, "", http.StatusBadRequest) + return + } + } + } + rteam, err := c.App.CreateTeamWithUser(c.AppContext, &team, c.AppContext.Session().UserId) if err != nil { c.Err = err @@ -258,6 +281,28 @@ func restoreTeam(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionManageTeam) return } + // Freemium enabled, on a cloud license. We must check limits before allowing to restore + if c.App.Config().FeatureFlags != nil && c.App.Config().FeatureFlags.CloudFree && (c.App.Channels().License() != nil && c.App.Channels().License().Features != nil && *c.App.Channels().License().Features.Cloud) { + limits, err := c.App.Cloud().GetCloudLimits(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.restoreTeam", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + // If there are no limits for teams, for active teams, or the limit for active teams is less than 0, do nothing + if !(limits == nil || limits.Teams == nil || limits.Teams.Active == nil || *limits.Teams.Active <= 0) { + teamsUsage, appErr := c.App.GetTeamsUsage() + if appErr != nil { + c.Err = appErr + return + } + // if the number of active teams is greater than or equal to the limit, return 400 + if teamsUsage.Active >= int64(*limits.Teams.Active) { + c.Err = model.NewAppError("Api4.restoreTeam", "api.cloud.teams_limit_reached.restore", nil, "", http.StatusBadRequest) + return + } + } + } err := c.App.RestoreTeam(c.Params.TeamId) if err != nil { diff --git a/api4/team_test.go b/api4/team_test.go index 9ec7fc747e..079c5fbc06 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "net/http" + "os" "strconv" "strings" "testing" @@ -17,7 +18,9 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v6/app" + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/shared/mail" "github.com/mattermost/mattermost-server/v6/utils/testutils" @@ -67,27 +70,78 @@ func TestCreateTeam(t *testing.T) { assert.Equal(t, *rteam.GroupConstrained, *groupConstrainedTeam.GroupConstrained, "GroupConstrained flags do not match") }) - th.Client.Logout() + t.Run("unauthenticated receives 403", func(t *testing.T) { + th.Client.Logout() - team := &model.Team{Name: GenerateTestUsername(), DisplayName: "Some Team", Type: model.TeamOpen} - _, resp, err := th.Client.CreateTeam(team) - require.Error(t, err) - CheckUnauthorizedStatus(t, resp) + team := &model.Team{Name: GenerateTestUsername(), DisplayName: "Some Team", Type: model.TeamOpen} + _, resp, err := th.Client.CreateTeam(team) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) - th.LoginBasic() + th.LoginBasic() - // Check the appropriate permissions are enforced. - defaultRolePermissions := th.SaveDefaultRolePermissions() - defer func() { - th.RestoreDefaultRolePermissions(defaultRolePermissions) - }() + // Check the appropriate permissions are enforced. + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer func() { + th.RestoreDefaultRolePermissions(defaultRolePermissions) + }() - th.RemovePermissionFromRole(model.PermissionCreateTeam.Id, model.SystemUserRoleId) - th.AddPermissionToRole(model.PermissionCreateTeam.Id, model.SystemAdminRoleId) + th.RemovePermissionFromRole(model.PermissionCreateTeam.Id, model.SystemUserRoleId) + th.AddPermissionToRole(model.PermissionCreateTeam.Id, model.SystemAdminRoleId) - _, resp, err = th.Client.CreateTeam(team) - require.Error(t, err) - CheckForbiddenStatus(t, resp) + _, resp, err = th.Client.CreateTeam(team) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("cloud limit reached returns 400", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Teams: &model.TeamsLimits{ + Active: model.NewInt(1), + }, + }, nil).Once() + team := &model.Team{Name: GenerateTestUsername(), DisplayName: "Some Team", Type: model.TeamOpen} + _, resp, err := th.Client.CreateTeam(team) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("cloud below limit returns 200", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + defer th.App.ReloadConfig() + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Teams: &model.TeamsLimits{ + Active: model.NewInt(200), + }, + }, nil).Once() + team := &model.Team{Name: GenerateTestUsername(), DisplayName: "Some Team", Type: model.TeamOpen} + _, resp, err := th.Client.CreateTeam(team) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + }) } func TestCreateTeamSanitization(t *testing.T) { @@ -578,6 +632,56 @@ func TestRestoreTeam(t *testing.T) { require.NoError(t, err) CheckOKStatus(t, resp) }) + + t.Run("cloud limit reached returns 400", func(t *testing.T) { + // Create an archived team to be restored later + team := createTeam(t, true, model.TeamOpen) + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Teams: &model.TeamsLimits{ + Active: model.NewInt(1), + }, + }, nil).Once() + + _, resp, err := client.RestoreTeam(team.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("cloud below limit returns 200", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Teams: &model.TeamsLimits{ + Active: model.NewInt(200), + }, + }, nil).Twice() + team := createTeam(t, true, model.TeamOpen) + _, resp, err := client.RestoreTeam(team.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) + }) } func TestPatchTeamSanitization(t *testing.T) { diff --git a/api4/usage.go b/api4/usage.go index 583ab03b9d..9424331927 100644 --- a/api4/usage.go +++ b/api4/usage.go @@ -13,6 +13,8 @@ import ( func (api *API) InitUsage() { // GET /api/v4/usage/posts api.BaseRoutes.Usage.Handle("/posts", api.APISessionRequired(getPostsUsage)).Methods("GET") + // GET /api/v4/usage/teams + api.BaseRoutes.Usage.Handle("/teams", api.APISessionRequired(getTeamsUsage)).Methods("GET") // GET /api/v4/usage/integrations api.BaseRoutes.Usage.Handle("/integrations", api.APISessionRequired(getIntegrationsUsage)).Methods("GET") @@ -34,6 +36,24 @@ func getPostsUsage(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } +func getTeamsUsage(c *Context, w http.ResponseWriter, r *http.Request) { + teamsUsage, appErr := c.App.GetTeamsUsage() + if appErr != nil { + c.Err = model.NewAppError("Api4.getTeamsUsage", "app.teams.analytics_teams_count.app_error", nil, appErr.Error(), http.StatusInternalServerError) + return + } + + if teamsUsage == nil { + c.Err = model.NewAppError("Api4.getTeamsUsage", "app.teams.analytics_teams_count.app_error", nil, appErr.Error(), http.StatusInternalServerError) + } + + json, err := json.Marshal(teamsUsage) + if err != nil { + c.Err = model.NewAppError("Api4.getTeamsUsage", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + } + w.Write(json) +} + func getIntegrationsUsage(c *Context, w http.ResponseWriter, r *http.Request) { if !*c.App.Config().PluginSettings.Enable { json, err := json.Marshal(&model.IntegrationsUsage{}) diff --git a/api4/usage_test.go b/api4/usage_test.go index 5c6900f0ef..b8fe12e168 100644 --- a/api4/usage_test.go +++ b/api4/usage_test.go @@ -50,6 +50,34 @@ func TestGetPostsUsage(t *testing.T) { }) } +func TestGetTeamsUsage(t *testing.T) { + t.Run("unauthenticated users can not access", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + th.Client.Logout() + + usage, r, err := th.Client.GetTeamsUsage() + assert.Error(t, err) + assert.Nil(t, usage) + assert.Equal(t, http.StatusUnauthorized, r.StatusCode) + }) + + t.Run("good request returns response", func(t *testing.T) { + // Following calls create a total of 3 teams + th := Setup(t).InitBasic() + defer th.TearDown() + th.CreateTeam() + th.CreateTeam() + + usage, r, err := th.Client.GetTeamsUsage() + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, r.StatusCode) + assert.NotNil(t, usage) + assert.Equal(t, int64(3), usage.Active) + }) +} + func TestGetIntegrationsUsage(t *testing.T) { t.Run("unauthenticated users can not access", func(t *testing.T) { th := Setup(t) diff --git a/app/app_iface.go b/app/app_iface.go index 61c54e9ccc..0589b33e7c 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -757,6 +757,7 @@ type AppIface interface { GetTeamsForSchemePage(scheme *model.Scheme, page int, perPage int) ([]*model.Team, *model.AppError) GetTeamsForUser(userID string) ([]*model.Team, *model.AppError) GetTeamsUnreadForUser(excludeTeamId string, userID string, includeCollapsedThreads bool) ([]*model.TeamUnread, *model.AppError) + GetTeamsUsage() (*model.TeamsUsage, *model.AppError) GetTermsOfService(id string) (*model.TermsOfService, *model.AppError) GetThreadForUser(teamID string, threadMembership *model.ThreadMembership, extended bool) (*model.ThreadResponse, *model.AppError) GetThreadMembershipForUser(userId, threadId string) (*model.ThreadMembership, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 6a9b6a0ec2..474ff4b9ae 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -9596,6 +9596,28 @@ func (a *OpenTracingAppLayer) GetTeamsUnreadForUser(excludeTeamId string, userID return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetTeamsUsage() (*model.TeamsUsage, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTeamsUsage") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetTeamsUsage() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetTermsOfService(id string) (*model.TermsOfService, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTermsOfService") diff --git a/app/usage.go b/app/usage.go index 71a75d2193..599c94eb71 100644 --- a/app/usage.go +++ b/app/usage.go @@ -54,3 +54,31 @@ func (a *App) GetPostsUsage() (int64, *model.AppError) { return utils.RoundOffToZeroes(float64(count)), nil } + +func (a *App) GetTeamsUsage() (*model.TeamsUsage, *model.AppError) { + usage := &model.TeamsUsage{} + includeDeleted := false + teamCount, err := a.Srv().Store.Team().AnalyticsTeamCount(&model.TeamSearch{IncludeDeleted: &includeDeleted}) + if err != nil { + return nil, model.NewAppError("GetTeamsUsage", "app.post.analytics_teams_count.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + usage.Active = teamCount + + allTeams, appErr := a.GetAllTeams() + if appErr != nil { + return nil, appErr + } + + cloudArchivedTeamCount := 0 + + for _, team := range allTeams { + if team.DeleteAt > 0 && team.CloudLimitsArchived { + cloudArchivedTeamCount += 1 + } + } + + usage.CloudArchived = int64(cloudArchivedTeamCount) + + return usage, nil +} diff --git a/i18n/en.json b/i18n/en.json index 4074bdf377..afd5daa2e9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -483,6 +483,14 @@ "id": "api.cloud.subscription.update_error", "translation": "Error updating subscription from webhook." }, + { + "id": "api.cloud.teams_limit_reached.create", + "translation": "Unable to create team because teams limit has been reached" + }, + { + "id": "api.cloud.teams_limit_reached.restore", + "translation": "Unable to restore team because teams limit has been reached" + }, { "id": "api.command.admin_only.app_error", "translation": "Integrations have been limited to admins only." @@ -5803,6 +5811,10 @@ "id": "app.post.analytics_posts_count_by_day.app_error", "translation": "Unable to get post counts by day." }, + { + "id": "app.post.analytics_teams_count.app_error", + "translation": "Unable to get teams usage" + }, { "id": "app.post.analytics_user_counts_posts_by_day.app_error", "translation": "Unable to get user counts with posts." @@ -6283,6 +6295,10 @@ "id": "app.team.user_belongs_to_teams.app_error", "translation": "Unable to determine if the user belongs to a list of teams." }, + { + "id": "app.teams.analytics_teams_count.app_error", + "translation": "Unable to get team count" + }, { "id": "app.terms_of_service.create.app_error", "translation": "Unable to save terms of service." diff --git a/model/client4.go b/model/client4.go index fd3aeeff14..b0d2144e6a 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8111,6 +8111,20 @@ func (c *Client4) GetPostsUsage() (*PostsUsage, *Response, error) { return usage, BuildResponse(r), err } +// GetTeamsUsage returns total usage of teams for the instance +// GetTeamsUsage returns total usage of teams for the instance +func (c *Client4) GetTeamsUsage() (*TeamsUsage, *Response, error) { + r, err := c.DoAPIGet(c.usageRoute()+"/teams", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var usage *TeamsUsage + err = json.NewDecoder(r.Body).Decode(&usage) + return usage, BuildResponse(r), err +} + // GetIntegrationsUsage returns usage information on integrations, including the count of enabled integrations func (c *Client4) GetIntegrationsUsage() (*IntegrationsUsage, *Response, error) { r, err := c.DoAPIGet(c.usageRoute()+"/integrations", "") diff --git a/model/usage.go b/model/usage.go index 4dfcabd14e..0a5fb07866 100644 --- a/model/usage.go +++ b/model/usage.go @@ -7,6 +7,11 @@ type PostsUsage struct { Count int64 `json:"count"` } +type TeamsUsage struct { + Active int64 `json:"active"` + CloudArchived int64 `json:"cloud_archived"` +} + type IntegrationsUsage struct { Enabled int `json:"enabled"` }