diff --git a/api/Makefile b/api/Makefile index 83280e6c11..57e62eac21 100644 --- a/api/Makefile +++ b/api/Makefile @@ -51,6 +51,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/exports.yaml >> $(V4_YAML) @cat $(V4_SRC)/ip_filters.yaml >> $(V4_YAML) @cat $(V4_SRC)/reports.yaml >> $(V4_YAML) + @cat $(V4_SRC)/limits.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 5d780a5223..798865264e 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3569,6 +3569,25 @@ components: state: description: The current state of the installation type: string + UserLimits: + type: object + properties: + maxUsersLimit: + description: The maximum number of users allowed on server + type: integer + format: int64 + lowerBandUserLimit: + description: User count after which to show the first upgrade message + type: integer + format: int64 + upperBandUserLimit: + description: User count after which to show the second upgrade message + type: integer + format: int64 + activeUserCount: + description: The number of active users in the server + type: integer + format: int64 externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/limits.yaml b/api/v4/source/limits.yaml new file mode 100644 index 0000000000..03d121ef80 --- /dev/null +++ b/api/v4/source/limits.yaml @@ -0,0 +1,30 @@ + /api/v4/limits/users: + get: + tags: + - users + summary: Gets the user limits for the server + description: > + Gets the user limits for the server + + ##### Permissions + + Requires `sysconsole_read_user_management_users`. + + operationId: getUserLimits + responses: + "200": + description: User limits for server + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UserLimits" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 0d5d762d8b..f2997f8a7f 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -141,6 +141,8 @@ type Routes struct { IPFiltering *mux.Router // 'api/v4/ip_filtering' Reports *mux.Router // 'api/v4/reports' + + Limits *mux.Router // 'api/v4/limits' } type API struct { @@ -269,6 +271,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Reports = api.BaseRoutes.APIRoot.PathPrefix("/reports").Subrouter() + api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -314,6 +318,7 @@ func Init(srv *app.Server) (*API, error) { api.InitDrafts() api.InitIPFiltering() api.InitReports() + api.InitLimits() srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/limits.go b/server/channels/api4/limits.go new file mode 100644 index 0000000000..54663a43c0 --- /dev/null +++ b/server/channels/api4/limits.go @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +func (api *API) InitLimits() { + api.BaseRoutes.Limits.Handle("/users", api.APISessionRequired(getUserLimits)).Methods("GET") +} + +func getUserLimits(c *Context, w http.ResponseWriter, r *http.Request) { + if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) { + c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers) + return + } + + userLimits, err := c.App.GetUserLimits() + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(userLimits); err != nil { + c.Logger.Error("Error writing user limits response", mlog.Err(err)) + } +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 1e019364cd..83d4ffa30a 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -826,6 +826,7 @@ type AppIface interface { GetUserByRemoteID(remoteID string) (*model.User, *model.AppError) GetUserByUsername(username string) (*model.User, *model.AppError) GetUserForLogin(c request.CTX, id, loginId string) (*model.User, *model.AppError) + GetUserLimits() (*model.UserLimits, *model.AppError) GetUserTermsOfService(userID string) (*model.UserTermsOfService, *model.AppError) GetUsers(userIDs []string) ([]*model.User, *model.AppError) GetUsersByGroupChannelIds(c request.CTX, channelIDs []string, asAdmin bool) (map[string][]*model.User, *model.AppError) diff --git a/server/channels/app/limits.go b/server/channels/app/limits.go new file mode 100644 index 0000000000..ecf613f1dd --- /dev/null +++ b/server/channels/app/limits.go @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + lowerBandUsersLimit = 500 + upperBandUsersLimit = 9000 + maxUsersLimit = 10000 +) + +func (a *App) GetUserLimits() (*model.UserLimits, *model.AppError) { + if !a.shouldShowUserLimits() { + return &model.UserLimits{}, nil + } + + activeUserCount, appErr := a.Srv().Store().User().Count(model.UserCountOptions{}) + if appErr != nil { + return nil, model.NewAppError("GetUsersLimits", "app.limits.get_user_limits.user_count.store_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + } + + return &model.UserLimits{ + ActiveUserCount: activeUserCount, + LowerBandUserLimit: lowerBandUsersLimit, + UpperBandUserLimit: upperBandUsersLimit, + MaxUsersLimit: maxUsersLimit, + }, nil +} + +func (a *App) shouldShowUserLimits() bool { + if maxUsersLimit == 0 { + return false + } + + return a.License() == nil +} diff --git a/server/channels/app/limits_test.go b/server/channels/app/limits_test.go new file mode 100644 index 0000000000..bbb39a6421 --- /dev/null +++ b/server/channels/app/limits_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestGetUserLimits(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("base case", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + + // InitBasic creates 3 users by default + require.Equal(t, int64(3), userLimits.ActiveUserCount) + require.Equal(t, int64(10000), userLimits.MaxUsersLimit) + require.Equal(t, int64(500), userLimits.LowerBandUserLimit) + require.Equal(t, int64(9000), userLimits.UpperBandUserLimit) + }) + + t.Run("user count should increase on creating new user and decrease on permanently deleting", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we create a new user + newUser := th.CreateUser() + + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(4), userLimits.ActiveUserCount) + + // now we'll delete the user + _ = th.App.PermanentDeleteUser(th.Context, newUser) + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + }) + + t.Run("user count should increase on creating new guest user and decrease on permanently deleting", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we create a new user + newGuestUser := th.CreateGuest() + + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(4), userLimits.ActiveUserCount) + + // now we'll delete the user + _ = th.App.PermanentDeleteUser(th.Context, newGuestUser) + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + }) + + t.Run("user count should increase on creating new user and decrease on soft deleting", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we create a new user + newUser := th.CreateUser() + + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(4), userLimits.ActiveUserCount) + + // now we'll delete the user + _, appErr = th.App.UpdateActive(th.Context, newUser, false) + require.Nil(t, appErr) + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + }) + + t.Run("user count should increase on creating new guest user and decrease on soft deleting", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we create a new user + newGuestUser := th.CreateGuest() + + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(4), userLimits.ActiveUserCount) + + // now we'll delete the user + _, appErr = th.App.UpdateActive(th.Context, newGuestUser, false) + require.Nil(t, appErr) + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + }) + + t.Run("user count should not change on creating or deleting bots", func(t *testing.T) { + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we create a new bot + newBot := th.CreateBot() + + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + + // now we'll delete the bot + _ = th.App.PermanentDeleteBot(newBot.UserId) + userLimits, appErr = th.App.GetUserLimits() + require.Nil(t, appErr) + require.Equal(t, int64(3), userLimits.ActiveUserCount) + }) + + t.Run("limits should be empty when there is a license", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicense()) + + userLimits, appErr := th.App.GetUserLimits() + require.Nil(t, appErr) + + require.Equal(t, int64(0), userLimits.ActiveUserCount) + require.Equal(t, int64(0), userLimits.MaxUsersLimit) + require.Equal(t, int64(0), userLimits.LowerBandUserLimit) + require.Equal(t, int64(0), userLimits.UpperBandUserLimit) + }) +} diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 98bf87fc4f..e5800cf40a 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -10555,6 +10555,28 @@ func (a *OpenTracingAppLayer) GetUserForLogin(c request.CTX, id string, loginId return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetUserLimits() (*model.UserLimits, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserLimits") + + 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.GetUserLimits() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetUserStatusesByIds(userIDs []string) ([]*model.Status, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserStatusesByIds") diff --git a/server/i18n/en.json b/server/i18n/en.json index b74c53e462..a88a43e8d8 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6046,6 +6046,10 @@ "id": "app.license.generate_renewal_token.no_license", "translation": "No license present" }, + { + "id": "app.limits.get_user_limits.user_count.store_error", + "translation": "Failed to get user count" + }, { "id": "app.login.doLogin.updateLastLogin.error", "translation": "Could not update last login timestamp" diff --git a/server/public/model/client4.go b/server/public/model/client4.go index ba23b9b07f..0b5dccbb29 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -567,6 +567,10 @@ func (c *Client4) permissionsRoute() string { return "/permissions" } +func (c *Client4) limitsRoute() string { + return "/limits" +} + func (c *Client4) DoAPIGet(ctx context.Context, url string, etag string) (*http.Response, error) { return c.DoAPIRequest(ctx, http.MethodGet, c.APIURL+url, "", etag) } @@ -8795,3 +8799,19 @@ func (c *Client4) SubmitTrueUpReview(ctx context.Context, req map[string]any) (* return BuildResponse(r), nil } + +func (c *Client4) GetUserLimits(ctx context.Context) (*UserLimits, *Response, error) { + r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var userLimits UserLimits + if r.StatusCode == http.StatusNotModified { + return &userLimits, BuildResponse(r), nil + } + if err := json.NewDecoder(r.Body).Decode(&userLimits); err != nil { + return nil, nil, NewAppError("GetUserLimits", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &userLimits, BuildResponse(r), nil +} diff --git a/server/public/model/limits.go b/server/public/model/limits.go new file mode 100644 index 0000000000..083d8bce0a --- /dev/null +++ b/server/public/model/limits.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type UserLimits struct { + MaxUsersLimit int64 `json:"maxUsersLimit"` // max number of users allowed + LowerBandUserLimit int64 `json:"lowerBandUserLimit"` // user count for 1st warning + UpperBandUserLimit int64 `json:"upperBandUserLimit"` // user count for 2nd warning + ActiveUserCount int64 `json:"activeUserCount"` // actual number of active users on server. Active = non deleted +} diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index d98b22e2c3..ea6e7e83ec 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -481,6 +481,10 @@ export default class Client4 { return `${this.getBaseRoute()}/reports`; } + getLimitsRoute(): string { + return `${this.getBaseRoute()}/limits`; + } + getCSRFFromCookie() { if (typeof document !== 'undefined' && typeof document.cookie !== 'undefined') { const cookies = document.cookie.split(';');