From 1b3a811cfd87ada0508f9356ad5801b758ceee64 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Tue, 21 Sep 2021 08:08:59 +0200 Subject: [PATCH] Add resource limiting for users --- app/boot_levels.go | 1 + auth/handlers/mock_test.go | 2 +- pkg/options/limit.gen.go | 33 ++++++++++++++++++++ pkg/options/limit.yaml | 50 ++++++++++++++++++++++++++++++ pkg/options/options.go | 2 ++ system/commands/users.go | 2 +- system/service/auth.go | 34 ++++++++++++++++++-- system/service/auth_actions.gen.go | 34 ++++++++++++++++++++ system/service/auth_actions.yaml | 4 +++ system/service/service.go | 5 +-- system/service/user.go | 40 +++++++++++++++++++++++- system/service/user_actions.gen.go | 39 ++++++++++++++++++++--- system/service/user_actions.yaml | 5 +-- tests/system/scim_test.go | 2 +- 14 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 pkg/options/limit.gen.go create mode 100644 pkg/options/limit.yaml diff --git a/app/boot_levels.go b/app/boot_levels.go index f4cd50683..910b46d44 100644 --- a/app/boot_levels.go +++ b/app/boot_levels.go @@ -366,6 +366,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) { Template: app.Opt.Template, Auth: app.Opt.Auth, RBAC: app.Opt.RBAC, + Limit: app.Opt.Limit, }) if err != nil { diff --git a/auth/handlers/mock_test.go b/auth/handlers/mock_test.go index 1a5331f65..e269748e2 100644 --- a/auth/handlers/mock_test.go +++ b/auth/handlers/mock_test.go @@ -307,7 +307,7 @@ func makeMockAuthService() *mockAuthService { opt: options.AuthOpt{}, } - serviceAuth := service.Auth() + serviceAuth := service.Auth(service.AuthOptions{}) svc := mockAuthService{ authService: serviceAuth, diff --git a/pkg/options/limit.gen.go b/pkg/options/limit.gen.go new file mode 100644 index 000000000..df7cf5aa1 --- /dev/null +++ b/pkg/options/limit.gen.go @@ -0,0 +1,33 @@ +package options + +// This file is auto-generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// Definitions file that controls how this file is generated: +// pkg/options/limit.yaml + +type ( + LimitOpt struct { + SystemUsers int `env:"LIMIT_SYSTEM_USERS"` + } +) + +// Limit initializes and returns a LimitOpt with default values +func Limit() (o *LimitOpt) { + o = &LimitOpt{} + + fill(o) + + // Function that allows access to custom logic inside the parent function. + // The custom logic in the other file should be like: + // func (o *Limit) Defaults() {...} + func(o interface{}) { + if def, ok := o.(interface{ Defaults() }); ok { + def.Defaults() + } + }(o) + + return +} diff --git a/pkg/options/limit.yaml b/pkg/options/limit.yaml new file mode 100644 index 000000000..2057e11cc --- /dev/null +++ b/pkg/options/limit.yaml @@ -0,0 +1,50 @@ +docs: + title: Limits + +props: + - name: systemUsers + type: int + description: |- + Maximum number of valid (not deleted, not suspended) users + +# @todo to be implemented +# - name: systemRoles +# type: int +# description: |- +# Maximum number of valid (not deleted, not archived) roles +# +# @todo to be implemented +# - name: systemGatewayRouts +# type: int +# description: |- +# Maximum number of valid (not deleted) gateway routes +# +# @todo to be implemented +# - name: systemTemplates +# type: int +# description: |- +# Maximum number of valid (not deleted) templates +# +# @todo to be implemented +# - name: composeNamespaces +# type: int +# description: |- +# Maximum number of valid (not deleted) compose namespaces +# +# @todo to be implemented +# - name: composeModules +# type: int +# description: |- +# Maximum number of valid (not deleted) compose modules accross all namespaces +# +# @todo to be implemented +# - name: composeRecords +# type: int +# description: |- +# Maximum number of valid (not deleted) compose records accross all namespaces and modules +# +# @todo to be implemented +# - name: automationWorkflows +# type: int +# description: |- +# Maximum number of valid (not deleted) automation workflows diff --git a/pkg/options/options.go b/pkg/options/options.go index c443aa16c..e1cdef2e2 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -25,6 +25,7 @@ type ( Workflow WorkflowOpt RBAC RBACOpt Locale LocaleOpt + Limit LimitOpt } ) @@ -53,5 +54,6 @@ func Init() *Options { Workflow: *Workflow(), RBAC: *RBAC(), Locale: *Locale(), + Limit: *Limit(), } } diff --git a/system/commands/users.go b/system/commands/users.go index 200f01ff8..0398f9d48 100644 --- a/system/commands/users.go +++ b/system/commands/users.go @@ -95,7 +95,7 @@ func Users(ctx context.Context, app serviceInitializer) *cobra.Command { ctx = auth.SetIdentityToContext(ctx, auth.ServiceUser()) var ( - authSvc = service.Auth() + authSvc = service.Auth(service.AuthOptions{}) // @todo email validation user = &types.User{Email: args[0]} diff --git a/system/service/auth.go b/system/service/auth.go index 4d1be0804..ed6f87ad0 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -33,9 +33,15 @@ type ( settings *types.AppSettings notifications AuthNotificationService + opt AuthOptions + providerValidator func(string) error } + AuthOptions struct { + LimitUsers int + } + authAccessController interface { CanImpersonateUser(context.Context, *types.User) bool CanUpdateUser(context.Context, *types.User) bool @@ -69,7 +75,7 @@ func defaultProviderValidator(provider string) error { } } -func Auth() *auth { +func Auth(opt AuthOptions) *auth { return &auth{ eventbus: eventbus.Service(), ac: DefaultAccessControl, @@ -80,6 +86,8 @@ func Auth() *auth { store: DefaultStore, providerValidator: defaultProviderValidator, + + opt: opt, } } @@ -220,6 +228,10 @@ func (svc auth) External(ctx context.Context, profile types.ExternalAuthUser) (u return err } + if err = svc.checkLimits(ctx); err != nil { + return err + } + u.ID = nextID() u.CreatedAt = *now() if err = store.CreateUser(ctx, svc.store, u); err != nil { @@ -378,6 +390,10 @@ func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password return err } + if err = svc.checkLimits(ctx); err != nil { + return err + } + // Whitelisted user data to copy err = store.CreateUser(ctx, svc.store, nUser) if err != nil { @@ -1060,7 +1076,7 @@ func (svc auth) autoPromote(ctx context.Context, u *types.User) (err error) { aam = &authActionProps{user: u, role: &types.Role{}} ) - if c, err = store.CountUsers(ctx, svc.store, types.UserFilter{Kind: types.NormalUser}); err != nil { + if c, err = countValidUsers(ctx, svc.store); err != nil { return err } @@ -1428,3 +1444,17 @@ func (svc auth) LoadRoleMemberships(ctx context.Context, u *types.User) error { func (svc auth) GetProviders() types.ExternalAuthProviderSet { return CurrentSettings.Auth.External.Providers } + +func (svc auth) checkLimits(ctx context.Context) error { + if svc.opt.LimitUsers == 0 { + return nil + } + + if c, err := countValidUsers(ctx, svc.store); err != nil { + return err + } else if c >= uint(svc.opt.LimitUsers) { + return AuthErrMaxUserLimitReached() + } + + return nil +} diff --git a/system/service/auth_actions.gen.go b/system/service/auth_actions.gen.go index cade20af2..487994170 100644 --- a/system/service/auth_actions.gen.go +++ b/system/service/auth_actions.gen.go @@ -1728,6 +1728,40 @@ func AuthErrInvalidEmailOTP(mm ...*authActionProps) *errors.Error { return e } +// AuthErrMaxUserLimitReached returns "system:auth.maxUserLimitReached" as *errors.Error +// +// +// This function is auto-generated. +// +func AuthErrMaxUserLimitReached(mm ...*authActionProps) *errors.Error { + var p = &authActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("you have reached your user limit, contact your Corteza administrator", nil), + + errors.Meta("type", "maxUserLimitReached"), + errors.Meta("resource", "system:auth"), + + errors.Meta(authPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "system"), + errors.Meta(locale.ErrorMetaKey{}, "auth.errors.maxUserLimitReached"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // ********************************************************************************************************************* // ********************************************************************************************************************* diff --git a/system/service/auth_actions.yaml b/system/service/auth_actions.yaml index 2a2f178e5..69f8fe2d9 100644 --- a/system/service/auth_actions.yaml +++ b/system/service/auth_actions.yaml @@ -200,3 +200,7 @@ errors: - error: invalidEmailOTP message: "invalid code" severity: warning + + - error: maxUserLimitReached + message: "you have reached your user limit, contact your Corteza administrator" + severity: warning diff --git a/system/service/service.go b/system/service/service.go index cfa542c80..b872488b4 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -33,6 +33,7 @@ type ( Template options.TemplateOpt Auth options.AuthOpt RBAC options.RBACOpt + Limit options.LimitOpt } eventDispatcher interface { @@ -162,9 +163,9 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, ws websock DefaultRenderer = Renderer(c.Template) DefaultReport = Report(DefaultStore, DefaultAccessControl, DefaultActionlog, eventbus.Service()) DefaultAuthNotification = AuthNotification(CurrentSettings, DefaultRenderer, c.Auth) - DefaultAuth = Auth() + DefaultAuth = Auth(AuthOptions{LimitUsers: c.Limit.SystemUsers}) DefaultAuthClient = AuthClient(DefaultStore, DefaultAccessControl, DefaultActionlog, eventbus.Service(), c.Auth) - DefaultUser = User() + DefaultUser = User(UserOptions{LimitUsers: c.Limit.SystemUsers}) DefaultRole = Role() DefaultApplication = Application(DefaultStore, DefaultAccessControl, DefaultActionlog, eventbus.Service()) DefaultReminder = Reminder(ctx, DefaultLogger.Named("reminder"), ws) diff --git a/system/service/user.go b/system/service/user.go index e8e412e92..b09f649c3 100644 --- a/system/service/user.go +++ b/system/service/user.go @@ -38,12 +38,18 @@ type ( store store.Storer + opt UserOptions + // List (cache) of preloaded users, accessible by handle // // It also does negative caching by assigning empty User structs preloaded map[string]*types.User } + UserOptions struct { + LimitUsers int + } + userAuth interface { CheckPasswordStrength(string) bool SetPasswordCredentials(context.Context, uint64, string) error @@ -89,7 +95,7 @@ type ( } ) -func User() *user { +func User(opt UserOptions) *user { return &user{ eventbus: eventbus.Service(), ac: DefaultAccessControl, @@ -100,6 +106,8 @@ func User() *user { actionlog: DefaultActionlog, + opt: opt, + preloaded: make(map[string]*types.User), } } @@ -362,6 +370,10 @@ func (svc user) Create(ctx context.Context, new *types.User) (u *types.User, err return UserErrInvalidEmail() } + if err = svc.checkLimits(ctx); err != nil { + return err + } + if err = svc.eventbus.WaitFor(ctx, event.UserBeforeCreate(new, u)); err != nil { return } @@ -577,6 +589,10 @@ func (svc user) Undelete(ctx context.Context, userID uint64) (err error) { return UserErrNotAllowedToDelete() } + if err = svc.checkLimits(ctx); err != nil { + return err + } + if !svc.ac.CanDeleteUser(ctx, u) { return UserErrNotAllowedToDelete() } @@ -663,6 +679,10 @@ func (svc user) Unsuspend(ctx context.Context, userID uint64) (err error) { return UserErrNotAllowedToUnsuspend() } + if err = svc.checkLimits(ctx); err != nil { + return err + } + u.SuspendedAt = nil if err = store.UpdateUser(ctx, svc.store, u); err != nil { return @@ -795,6 +815,24 @@ func (svc user) DeleteAuthSessionsByUserID(ctx context.Context, userID uint64) ( return svc.recordAction(ctx, uaProps, UserActionDeleteAuthSessions, err) } +func (svc user) checkLimits(ctx context.Context) error { + if svc.opt.LimitUsers == 0 { + return nil + } + + if c, err := countValidUsers(ctx, svc.store); err != nil { + return err + } else if c >= uint(svc.opt.LimitUsers) { + return UserErrMaxUserLimitReached() + } + + return nil +} + +func countValidUsers(ctx context.Context, s store.Users) (c uint, err error) { + return store.CountUsers(ctx, s, types.UserFilter{Kind: types.NormalUser}) +} + // UniqueCheck verifies user's email, username and handle func uniqueUserCheck(ctx context.Context, s store.Storer, u *types.User) (err error) { isUnique := func(field string) bool { diff --git a/system/service/user_actions.gen.go b/system/service/user_actions.gen.go index 46b081ce3..150f6aacf 100644 --- a/system/service/user_actions.gen.go +++ b/system/service/user_actions.gen.go @@ -1266,11 +1266,6 @@ func UserErrPasswordNotSecure(mm ...*userActionProps) *errors.Error { errors.Meta("type", "passwordNotSecure"), errors.Meta("resource", "system:user"), - // link to documentation; formatting applies in case we need some special link formatting - errors.Meta("documentation", p.Format("foo", nil)), - - // details, used in detailed eror reporting - errors.Meta("details", p.Format("foo bar", nil)), errors.Meta(userPropsMetaKey{}, p), // translation namespace & key @@ -1286,6 +1281,40 @@ func UserErrPasswordNotSecure(mm ...*userActionProps) *errors.Error { return e } +// UserErrMaxUserLimitReached returns "system:user.maxUserLimitReached" as *errors.Error +// +// +// This function is auto-generated. +// +func UserErrMaxUserLimitReached(mm ...*userActionProps) *errors.Error { + var p = &userActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("you have reached your user limit, contact your Corteza administrator", nil), + + errors.Meta("type", "maxUserLimitReached"), + errors.Meta("resource", "system:user"), + + errors.Meta(userPropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "system"), + errors.Meta(locale.ErrorMetaKey{}, "user.errors.maxUserLimitReached"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // ********************************************************************************************************************* // ********************************************************************************************************************* diff --git a/system/service/user_actions.yaml b/system/service/user_actions.yaml index 42be7aca1..5877a88da 100644 --- a/system/service/user_actions.yaml +++ b/system/service/user_actions.yaml @@ -146,6 +146,7 @@ errors: - error: passwordNotSecure message: "provided password is not secure; use longer password with more non-alphanumeric character" - documentation: foo - details: foo bar + - error: maxUserLimitReached + message: "you have reached your user limit, contact your Corteza administrator" + severity: warning diff --git a/tests/system/scim_test.go b/tests/system/scim_test.go index 13c532a79..122959fcf 100644 --- a/tests/system/scim_test.go +++ b/tests/system/scim_test.go @@ -177,7 +177,7 @@ func TestScimUserPassword(t *testing.T) { h.clearUsers() service.CurrentSettings.Auth.Internal.Enabled = true - auth := service.Auth() + auth := service.Auth(service.AuthOptions{}) h.scimApiInit(). Post("/Users").