diff --git a/api/system/spec.json b/api/system/spec.json index 9c1af7c4b..70c3869f5 100644 --- a/api/system/spec.json +++ b/api/system/spec.json @@ -310,6 +310,20 @@ } ] }, + { + "title": "Subscription", + "path": "/subscription", + "entrypoint": "subscription", + "apis": [ + { + "name": "current", + "method": "GET", + "title": "Returns current subscription status", + "path": "/", + "parameters": {} + } + ] + }, { "title": "Organisations", "description": "Organisations represent a top-level grouping entity. There may be many organisations defined in a single deployment.", diff --git a/api/system/spec/subscription.json b/api/system/spec/subscription.json new file mode 100644 index 000000000..56e07c8ba --- /dev/null +++ b/api/system/spec/subscription.json @@ -0,0 +1,18 @@ +{ + "Title": "Subscription", + "Interface": "Subscription", + "Struct": null, + "Parameters": null, + "Protocol": "", + "Authentication": null, + "Path": "/subscription", + "APIs": [ + { + "Name": "current", + "Method": "GET", + "Title": "Returns current subscription status", + "Path": "/", + "Parameters": {} + } + ] +} \ No newline at end of file diff --git a/docs/system/README.md b/docs/system/README.md index e81bcb130..0631319a6 100644 --- a/docs/system/README.md +++ b/docs/system/README.md @@ -1078,6 +1078,32 @@ An organisation may have many roles. Roles may have many channels available. Acc +# Subscription + +| Method | Endpoint | Purpose | +| ------ | -------- | ------- | +| `GET` | `/subscription/` | Returns current subscription status | + +## Returns current subscription status + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/subscription/` | HTTP/S | GET | +Warning: implode(): Invalid arguments passed in /private/tmp/Users/darh/Work.crust/corteza-server/codegen/templates/README.tpl on line 32 + | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | + +--- + + + + # Users | Method | Endpoint | Purpose | diff --git a/system/rest/auth.go b/system/rest/auth.go index 2c9e05c1a..da7327d26 100644 --- a/system/rest/auth.go +++ b/system/rest/auth.go @@ -75,7 +75,14 @@ func (ctrl *Auth) Logout(ctx context.Context, r *request.AuthLogout) (interface{ } func (ctrl *Auth) Settings(ctx context.Context, r *request.AuthSettings) (interface{}, error) { - return ctrl.authSettings.Format(), nil + f := ctrl.authSettings.Format() + + if err := ctrl.authSvc.With(ctx).CanRegister(); err != nil { + // f["internalSignUpEnabled"] = false + f["signUpDisabled"] = err.Error() + } + + return f, nil } func (ctrl *Auth) ExchangeAuthToken(ctx context.Context, r *request.AuthExchangeAuthToken) (interface{}, error) { diff --git a/system/rest/handlers/subscription.go b/system/rest/handlers/subscription.go new file mode 100644 index 000000000..01a6061da --- /dev/null +++ b/system/rest/handlers/subscription.go @@ -0,0 +1,70 @@ +package handlers + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `subscription.go`, `subscription.util.go` or `subscription_test.go` to + implement your API calls, helper functions and tests. The file `subscription.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "context" + + "net/http" + + "github.com/go-chi/chi" + "github.com/titpetric/factory/resputil" + + "github.com/cortezaproject/corteza-server/pkg/logger" + "github.com/cortezaproject/corteza-server/system/rest/request" +) + +// Internal API interface +type SubscriptionAPI interface { + Current(context.Context, *request.SubscriptionCurrent) (interface{}, error) +} + +// HTTP API interface +type Subscription struct { + Current func(http.ResponseWriter, *http.Request) +} + +func NewSubscription(h SubscriptionAPI) *Subscription { + return &Subscription{ + Current: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewSubscriptionCurrent() + if err := params.Fill(r); err != nil { + logger.LogParamError("Subscription.Current", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.Current(r.Context(), params) + if err != nil { + logger.LogControllerError("Subscription.Current", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Subscription.Current", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, + } +} + +func (h Subscription) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http.Handler) { + r.Group(func(r chi.Router) { + r.Use(middlewares...) + r.Get("/subscription/", h.Current) + }) +} diff --git a/system/rest/request/subscription.go b/system/rest/request/subscription.go new file mode 100644 index 000000000..6070c9d82 --- /dev/null +++ b/system/rest/request/subscription.go @@ -0,0 +1,77 @@ +package request + +/* + Hello! This file is auto-generated from `docs/src/spec.json`. + + For development: + In order to update the generated files, edit this file under the location, + add your struct fields, imports, API definitions and whatever you want, and: + + 1. run [spec](https://github.com/titpetric/spec) in the same folder, + 2. run `./_gen.php` in this folder. + + You may edit `subscription.go`, `subscription.util.go` or `subscription_test.go` to + implement your API calls, helper functions and tests. The file `subscription.go` + is only generated the first time, and will not be overwritten if it exists. +*/ + +import ( + "io" + "strings" + + "encoding/json" + "mime/multipart" + "net/http" + + "github.com/go-chi/chi" + "github.com/pkg/errors" +) + +var _ = chi.URLParam +var _ = multipart.FileHeader{} + +// Subscription current request parameters +type SubscriptionCurrent struct { +} + +func NewSubscriptionCurrent() *SubscriptionCurrent { + return &SubscriptionCurrent{} +} + +func (r SubscriptionCurrent) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + return out +} + +func (r *SubscriptionCurrent) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + return err +} + +var _ RequestFiller = NewSubscriptionCurrent() diff --git a/system/rest/router.go b/system/rest/router.go index 82b14bf25..4d6b0b17e 100644 --- a/system/rest/router.go +++ b/system/rest/router.go @@ -27,6 +27,7 @@ func MountRoutes(r chi.Router) { r.Group(func(r chi.Router) { r.Use(auth.MiddlewareValidOnly) + handlers.NewSubscription(Subscription{}.New()).MountRoutes(r) handlers.NewUser(User{}.New()).MountRoutes(r) handlers.NewRole(Role{}.New()).MountRoutes(r) handlers.NewOrganisation(Organisation{}.New()).MountRoutes(r) diff --git a/system/rest/subscription.go b/system/rest/subscription.go new file mode 100644 index 000000000..cd32028eb --- /dev/null +++ b/system/rest/subscription.go @@ -0,0 +1,50 @@ +package rest + +import ( + "context" + "net/http" + "strings" + + "github.com/titpetric/factory/resputil" + + "github.com/cortezaproject/corteza-server/system/rest/request" + "github.com/cortezaproject/corteza-server/system/service" +) + +type Subscription struct{} + +func (Subscription) New() *Subscription { + return &Subscription{} +} + +func (ctrl *Subscription) Current(ctx context.Context, r *request.SubscriptionCurrent) (interface{}, error) { + if service.CurrentSubscription == nil { + // Nothing to do here + return resputil.OK(), nil + } + + // Returning function that gets called with writter & request + // + // This is the only way to get to the request URL we need to do a domain check + // for the permit + return func(w http.ResponseWriter, req *http.Request) { + var ( + domain = req.Host + pos = strings.IndexByte(domain, ':') + + // Anyone that has access permissions is considered admin + isAdmin = service.DefaultAccessControl.CanAccess(ctx) + ) + + if pos > -1 { + // Strip port + domain = domain[:pos] + } + + if err := service.CurrentSubscription.Validate(domain, isAdmin); err != nil { + resputil.JSON(w, err) + } else { + resputil.JSON(w, resputil.OK()) + } + }, nil +} diff --git a/system/service/auth.go b/system/service/auth.go index 481b3af82..d6ef3dc2c 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -26,6 +26,7 @@ type ( ctx context.Context logger *zap.Logger + subscription authSubscriptionChecker credentials repository.CredentialsRepository users repository.UserRepository roles repository.RoleRepository @@ -55,11 +56,17 @@ type ( SendEmailAddressConfirmationToken(email string) (err error) SendPasswordResetToken(email string) (err error) + CanRegister() error + LoadRoleMemberships(*types.User) error checkPasswordStrength(string) error changePassword(uint64, string) error } + + authSubscriptionChecker interface { + CanRegister(uint) error + } ) const ( @@ -98,6 +105,7 @@ func (svc auth) With(ctx context.Context) AuthService { users: repository.User(ctx, db), roles: repository.Role(ctx, db), + subscription: CurrentSubscription, settings: DefaultAuthSettings, notifications: DefaultAuthNotification, @@ -205,6 +213,10 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { Handle: profile.NickName, } + if err = svc.CanRegister(); err != nil { + return err + } + if u, err = svc.users.Create(u); err != nil { return errors.Wrap(err, "could not create user after successful external authentication") } @@ -316,6 +328,10 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use return nil, errors.Wrap(err, "could not check existing emails") } + if err = svc.CanRegister(); err != nil { + return nil, err + } + // Whitelisted user data to copy u, err = svc.users.Create(&types.User{ Email: input.Email, @@ -699,6 +715,18 @@ func (svc auth) SendPasswordResetToken(email string) error { return svc.sendPasswordResetToken(u) } +func (svc auth) CanRegister() error { + + if svc.subscription != nil { + // When we have an active subscription, we need to check + // if users can register or did this deployment hit + // it's user-limit + return svc.subscription.CanRegister(svc.users.Total()) + } + + return nil +} + func (svc auth) sendPasswordResetToken(u *types.User) (err error) { log := svc.log(svc.ctx, zap.Uint64("userID", u.ID), zap.String("email", u.Email)) diff --git a/system/service/service.go b/system/service/service.go index 841148519..c5893d1f2 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -36,11 +36,28 @@ type ( Corredor options.CorredorOpt GRPCClientSystem options.GRPCServerOpt } + + permitChecker interface { + Validate(string, bool) error + CanCreateUser(uint) error + CanRegister(uint) error + } ) var ( DefaultLogger *zap.Logger + // CurrentSubscription holds current subscription info, + // and functions for domain validation, user limit checks and + // warning texts + // + // By default, Corteza (community edition) has this set to nil + // and with that all checks & validations are skipped + // + // Other flavours or distributions can set this to + // something that suits their needs. + CurrentSubscription permitChecker + // DefaultPermissions Retrieves & stores permissions DefaultPermissions permissionServicer @@ -80,7 +97,6 @@ func Init(ctx context.Context, log *zap.Logger, c Config) (err error) { DefaultLogger = log.Named("service") DefaultIntSettings = internalSettings.NewService(internalSettings.NewRepository(repository.DB(ctx), "sys_settings")) - if DefaultPermissions == nil { DefaultPermissions = permissions.Service(ctx, DefaultLogger, repository.DB(ctx), "sys_permission_rules") } diff --git a/system/service/user.go b/system/service/user.go index 36b8cb6a4..1040fa23d 100644 --- a/system/service/user.go +++ b/system/service/user.go @@ -35,7 +35,8 @@ type ( settings *AuthSettings - auth userAuth + auth userAuth + subscription userSubscriptionChecker ac userAccessController user repository.UserRepository @@ -52,6 +53,10 @@ type ( changePassword(uint64, string) error } + userSubscriptionChecker interface { + CanCreateUser(uint) error + } + userAccessController interface { CanAccess(context.Context) bool CanCreateUser(context.Context) bool @@ -113,6 +118,8 @@ func (svc user) With(ctx context.Context) UserService { settings: DefaultAuthSettings, auth: DefaultAuth, + subscription: CurrentSubscription, + user: repository.User(ctx, db), credentials: repository.Credentials(ctx, db), @@ -199,6 +206,16 @@ func (svc user) Create(input *types.User) (out *types.User, err error) { return nil, ErrNoCreatePermissions.withStack() } + if svc.subscription != nil { + // When we have an active subscription, we need to check + // if users can be creare or did this deployment hit + // it's user-limit + err = svc.subscription.CanCreateUser(svc.user.Total()) + if err != nil { + return nil, err + } + } + return out, svc.db.Transaction(func() (err error) { if err = svc.UniqueCheck(input); err != nil { return