diff --git a/ee/http/middleware/pat.go b/ee/http/middleware/pat.go index 0f4ea5d755da..f99bfdf3b880 100644 --- a/ee/http/middleware/pat.go +++ b/ee/http/middleware/pat.go @@ -61,14 +61,14 @@ func (p *Pat) Wrap(next http.Handler) http.Handler { return } - role, err := authtypes.NewRole(user.Role) + role, err := types.NewRole(user.Role) if err != nil { next.ServeHTTP(w, r) return } jwt := authtypes.Claims{ - UserID: user.ID, + UserID: user.ID.String(), Role: role, Email: user.Email, OrgID: user.OrgID, diff --git a/ee/modules/user/impluser/handler.go b/ee/modules/user/impluser/handler.go new file mode 100644 index 000000000000..9335bdba8e81 --- /dev/null +++ b/ee/modules/user/impluser/handler.go @@ -0,0 +1,201 @@ +package impluser + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" + "github.com/SigNoz/signoz/pkg/types" + "github.com/gorilla/mux" +) + +// EnterpriseHandler embeds the base handler implementation +type Handler struct { + user.Handler // Embed the base handler interface + module user.Module +} + +func NewHandler(module user.Module) user.Handler { + baseHandler := impluser.NewHandler(module) + return &Handler{ + Handler: baseHandler, + module: module, + } +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + var req types.PostableLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(w, err) + return + } + + // the EE handler wrapper passes the feature flag value in context + ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool) + if !ok { + render.Error(w, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability")) + return + } + + if ssoAvailable { + _, err := h.module.CanUsePassword(ctx, req.Email) + if err != nil { + render.Error(w, err) + return + } + } + + user, err := h.module.GetAuthenticatedUser(ctx, req.OrgID, req.Email, req.Password, req.RefreshToken) + if err != nil { + render.Error(w, err) + return + } + + jwt, err := h.module.GetJWTForUser(ctx, user) + if err != nil { + render.Error(w, err) + return + } + + gettableLoginResponse := &types.GettableLoginResponse{ + GettableUserJwt: jwt, + UserID: user.ID.String(), + } + + render.Success(w, http.StatusOK, gettableLoginResponse) +} + +// Override only the methods you need with enterprise-specific implementations +func (h *Handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // assume user is valid unless proven otherwise and assign default values for rest of the fields + + email := r.URL.Query().Get("email") + sourceUrl := r.URL.Query().Get("ref") + orgID := r.URL.Query().Get("orgID") + + resp, err := h.module.LoginPrecheck(ctx, orgID, email, sourceUrl) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, resp) + +} + +func (h *Handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := new(types.PostableAcceptInvite) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user")) + return + } + + // get invite object + invite, err := h.module.GetInviteByToken(ctx, req.InviteToken) + if err != nil { + render.Error(w, err) + return + } + + orgDomain, err := h.module.GetAuthDomainByEmail(ctx, invite.Email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + render.Error(w, err) + return + } + + precheckResp := &types.GettableLoginPrecheck{ + SSO: false, + IsUser: false, + } + + if invite.Name == "" && req.DisplayName != "" { + invite.Name = req.DisplayName + } + + user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID) + if err != nil { + render.Error(w, err) + return + } + + if orgDomain != nil && orgDomain.SsoEnabled { + // sso is enabled, create user and respond precheck data + err = h.module.CreateUser(ctx, user) + if err != nil { + render.Error(w, err) + return + } + + // check if sso is enforced for the org + precheckResp, err = h.module.LoginPrecheck(ctx, invite.OrgID, user.Email, req.SourceURL) + if err != nil { + render.Error(w, err) + return + } + + } else { + password, err := types.NewFactorPassword(req.Password) + if err != nil { + render.Error(w, err) + return + } + + user, err = h.module.CreateUserWithPassword(ctx, user, password) + if err != nil { + render.Error(w, err) + return + } + + precheckResp.IsUser = true + } + + // delete the invite + if err := h.module.DeleteInvite(ctx, invite.OrgID, invite.ID); err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, precheckResp) +} + +func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + token := mux.Vars(r)["token"] + sourceUrl := r.URL.Query().Get("ref") + invite, err := h.module.GetInviteByToken(ctx, token) + if err != nil { + render.Error(w, err) + return + } + + // precheck the user + precheckResp, err := h.module.LoginPrecheck(ctx, invite.OrgID, invite.Email, sourceUrl) + if err != nil { + render.Error(w, err) + return + } + + gettableInvite := &types.GettableEEInvite{ + GettableInvite: *invite, + PreCheck: precheckResp, + } + + render.Success(w, http.StatusOK, gettableInvite) + return +} diff --git a/ee/modules/user/impluser/module.go b/ee/modules/user/impluser/module.go new file mode 100644 index 000000000000..dcc3bcab6d6f --- /dev/null +++ b/ee/modules/user/impluser/module.go @@ -0,0 +1,229 @@ +package impluser + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/SigNoz/signoz/ee/query-service/constants" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/modules/user" + baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "go.uber.org/zap" +) + +// EnterpriseModule embeds the base module implementation +type Module struct { + *baseimpl.Module // Embed the base module implementation + store types.UserStore +} + +func NewModule(store types.UserStore) user.Module { + baseModule := baseimpl.NewModule(store).(*baseimpl.Module) + return &Module{ + Module: baseModule, + store: store, + } +} + +func (m *Module) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) { + // get auth domain from email domain + _, err := m.GetAuthDomainByEmail(ctx, email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + + // get name from email + parts := strings.Split(email, "@") + if len(parts) < 2 { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format") + } + name := parts[0] + + defaultOrgID, err := m.store.GetDefaultOrgID(ctx) + if err != nil { + return nil, err + } + + user, err := types.NewUser(name, email, types.RoleViewer.String(), defaultOrgID) + if err != nil { + return nil, err + } + + err = m.CreateUser(ctx, user) + if err != nil { + return nil, err + } + + return user, nil +} + +func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) { + users, err := m.GetUsersByEmail(ctx, email) + if err != nil { + zap.L().Error("failed to get user with email received from auth provider", zap.String("error", err.Error())) + return "", err + } + user := &types.User{} + + if len(users) == 0 { + newUser, err := m.createUserForSAMLRequest(ctx, email) + user = newUser + if err != nil { + zap.L().Error("failed to create user with email received from auth provider", zap.Error(err)) + return "", err + } + } else { + user = &users[0].User + } + + tokenStore, err := m.GetJWTForUser(ctx, user) + if err != nil { + zap.L().Error("failed to generate token for SSO login user", zap.Error(err)) + return "", err + } + + return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s", + redirectUri, + tokenStore.AccessJwt, + user.ID, + tokenStore.RefreshJwt), nil +} + +func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) { + domain, err := m.GetAuthDomainByEmail(ctx, email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return false, err + } + + if domain != nil && domain.SsoEnabled { + // sso is enabled, check if the user has admin role + users, err := m.GetUsersByEmail(ctx, email) + if err != nil { + return false, err + } + + if len(users) == 0 { + return false, errors.New(errors.TypeNotFound, errors.CodeNotFound, "user not found") + } + + if users[0].Role != types.RoleAdmin.String() { + return false, errors.New(errors.TypeForbidden, errors.CodeForbidden, "auth method not supported") + } + + } + + return true, nil +} + +func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) { + resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false} + + // check if email is a valid user + users, err := m.GetUsersByEmail(ctx, email) + if err != nil { + return nil, err + } + + if len(users) == 0 { + resp.IsUser = false + } + + // give them an option to select an org + if orgID == "" && len(users) > 1 { + resp.SelectOrg = true + resp.Orgs = make([]string, len(users)) + for i, user := range users { + resp.Orgs[i] = user.OrgID + } + return resp, nil + } + + // select the user with the corresponding orgID + if len(users) > 1 { + found := false + for _, tuser := range users { + if tuser.OrgID == orgID { + // user = tuser + found = true + break + } + } + if !found { + resp.IsUser = false + return resp, nil + } + } + + // the EE handler wrapper passes the feature flag value in context + ssoAvailable, ok := ctx.Value(types.SSOAvailable).(bool) + if !ok { + zap.L().Error("failed to retrieve ssoAvailable from context") + return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to retrieve SSO availability") + } + + if ssoAvailable { + + // TODO(Nitya): in multitenancy this should use orgId as well. + orgDomain, err := m.GetAuthDomainByEmail(ctx, email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + + if orgDomain != nil && orgDomain.SsoEnabled { + // this is to allow self registration + resp.IsUser = true + + // saml is enabled for this domain, lets prepare sso url + if sourceUrl == "" { + sourceUrl = constants.GetDefaultSiteURL() + } + + // parse source url that generated the login request + var err error + escapedUrl, _ := url.QueryUnescape(sourceUrl) + siteUrl, err := url.Parse(escapedUrl) + if err != nil { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse referer") + } + + // build Idp URL that will authenticat the user + // the front-end will redirect user to this url + resp.SSOUrl, err = orgDomain.BuildSsoUrl(siteUrl) + if err != nil { + zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err)) + return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to prepare saml request for domain") + } + + // set SSO to true, as the url is generated correctly + resp.SSO = true + } + } + return resp, nil +} + +func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) { + + if email == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required") + } + + components := strings.Split(email, "@") + if len(components) < 2 { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format") + } + + domain, err := m.store.GetDomainByName(ctx, components[1]) + if err != nil { + return nil, err + } + + gettableDomain := &types.GettableOrgDomain{StorableOrgDomain: *domain} + if err := gettableDomain.LoadConfig(domain.Data); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to load domain config") + } + return gettableDomain, nil +} diff --git a/ee/modules/user/impluser/store.go b/ee/modules/user/impluser/store.go new file mode 100644 index 000000000000..cbd23478d79d --- /dev/null +++ b/ee/modules/user/impluser/store.go @@ -0,0 +1,37 @@ +package impluser + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + baseimpl "github.com/SigNoz/signoz/pkg/modules/user/impluser" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" +) + +type store struct { + *baseimpl.Store + sqlstore sqlstore.SQLStore +} + +func NewStore(sqlstore sqlstore.SQLStore) types.UserStore { + baseStore := baseimpl.NewStore(sqlstore).(*baseimpl.Store) + return &store{ + Store: baseStore, + sqlstore: sqlstore, + } +} + +func (s *store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { + domain := new(types.StorableOrgDomain) + err := s.sqlstore.BunDB().NewSelect(). + Model(domain). + Where("name = ?", name). + Limit(1). + Scan(ctx) + + if err != nil { + return nil, errors.Wrapf(err, errors.TypeNotFound, errors.CodeNotFound, "failed to get domain from name") + } + return domain, nil +} diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 1abc176995d1..8ab5ef668a71 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/http" "net/http/httputil" "time" @@ -9,10 +10,13 @@ import ( "github.com/SigNoz/signoz/ee/query-service/integrations/gateway" "github.com/SigNoz/signoz/ee/query-service/interfaces" "github.com/SigNoz/signoz/ee/query-service/license" + "github.com/SigNoz/signoz/ee/query-service/model" "github.com/SigNoz/signoz/ee/query-service/usage" "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/apis/fields" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/middleware" + "github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/modules/quickfilter" quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" baseapp "github.com/SigNoz/signoz/pkg/query-service/app" @@ -23,9 +27,11 @@ import ( basemodel "github.com/SigNoz/signoz/pkg/query-service/model" rules "github.com/SigNoz/signoz/pkg/query-service/rules" "github.com/SigNoz/signoz/pkg/signoz" + "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" "github.com/gorilla/mux" + "go.uber.org/zap" ) type APIHandlerOptions struct { @@ -120,43 +126,24 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { // routes available only in ee version - router.HandleFunc("/api/v1/featureFlags", - am.OpenAccess(ah.getFeatureFlags)). - Methods(http.MethodGet) + router.HandleFunc("/api/v1/featureFlags", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(ah.loginPrecheck)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/loginPrecheck", - am.OpenAccess(ah.precheckLogin)). - Methods(http.MethodGet) + // invite + router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(ah.acceptInvite)).Methods(http.MethodPost) // paid plans specific routes - router.HandleFunc("/api/v1/complete/saml", - am.OpenAccess(ah.receiveSAML)). - Methods(http.MethodPost) + router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.receiveSAML)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/complete/google", am.OpenAccess(ah.receiveGoogleAuth)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/orgs/{orgId}/domains", am.AdminAccess(ah.listDomainsByOrg)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/complete/google", - am.OpenAccess(ah.receiveGoogleAuth)). - Methods(http.MethodGet) - - router.HandleFunc("/api/v1/orgs/{orgId}/domains", - am.AdminAccess(ah.listDomainsByOrg)). - Methods(http.MethodGet) - - router.HandleFunc("/api/v1/domains", - am.AdminAccess(ah.postDomain)). - Methods(http.MethodPost) - - router.HandleFunc("/api/v1/domains/{id}", - am.AdminAccess(ah.putDomain)). - Methods(http.MethodPut) - - router.HandleFunc("/api/v1/domains/{id}", - am.AdminAccess(ah.deleteDomain)). - Methods(http.MethodDelete) + router.HandleFunc("/api/v1/domains", am.AdminAccess(ah.postDomain)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.putDomain)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/domains/{id}", am.AdminAccess(ah.deleteDomain)).Methods(http.MethodDelete) // base overrides router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(ah.getInvite)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/register", am.OpenAccess(ah.registerUser)).Methods(http.MethodPost) router.HandleFunc("/api/v1/login", am.OpenAccess(ah.loginUser)).Methods(http.MethodPost) // PAT APIs @@ -188,6 +175,54 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { } +// TODO(nitya): remove this once we know how to get the FF's +func (ah *APIHandler) updateRequestContext(w http.ResponseWriter, r *http.Request) (*http.Request, error) { + ssoAvailable := true + err := ah.FF().CheckFeature(model.SSO) + if err != nil { + switch err.(type) { + case basemodel.ErrFeatureUnavailable: + // do nothing, just skip sso + ssoAvailable = false + default: + zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err)) + return r, errors.New(errors.TypeInternal, errors.CodeInternal, "error checking SSO feature") + } + } + ctx := context.WithValue(r.Context(), types.SSOAvailable, ssoAvailable) + return r.WithContext(ctx), nil +} + +func (ah *APIHandler) loginPrecheck(w http.ResponseWriter, r *http.Request) { + r, err := ah.updateRequestContext(w, r) + if err != nil { + render.Error(w, err) + return + } + ah.Signoz.Handlers.User.LoginPrecheck(w, r) + return +} + +func (ah *APIHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { + r, err := ah.updateRequestContext(w, r) + if err != nil { + render.Error(w, err) + return + } + ah.Signoz.Handlers.User.AcceptInvite(w, r) + return +} + +func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { + r, err := ah.updateRequestContext(w, r) + if err != nil { + render.Error(w, err) + return + } + ah.Signoz.Handlers.User.GetInvite(w, r) + return +} + func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) { ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am) diff --git a/ee/query-service/app/api/auth.go b/ee/query-service/app/api/auth.go index 3f33d7d187b3..1d90df80d5be 100644 --- a/ee/query-service/app/api/auth.go +++ b/ee/query-service/app/api/auth.go @@ -9,13 +9,11 @@ import ( "net/http" "net/url" - "github.com/gorilla/mux" "go.uber.org/zap" "github.com/SigNoz/signoz/ee/query-service/constants" "github.com/SigNoz/signoz/ee/query-service/model" - baseauth "github.com/SigNoz/signoz/pkg/query-service/auth" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" + "github.com/SigNoz/signoz/pkg/http/render" ) func parseRequest(r *http.Request, req interface{}) error { @@ -31,161 +29,13 @@ func parseRequest(r *http.Request, req interface{}) error { // loginUser overrides base handler and considers SSO case. func (ah *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) { - - req := basemodel.LoginRequest{} - err := parseRequest(r, &req) + r, err := ah.updateRequestContext(w, r) if err != nil { - RespondError(w, model.BadRequest(err), nil) + render.Error(w, err) return } - - ctx := context.Background() - - if req.Email != "" && ah.CheckFeature(model.SSO) { - var apierr basemodel.BaseApiError - _, apierr = ah.AppDao().CanUsePassword(ctx, req.Email) - if apierr != nil && !apierr.IsNil() { - RespondError(w, apierr, nil) - } - } - - // if all looks good, call auth - resp, err := baseauth.Login(ctx, &req, ah.opts.JWT) - if ah.HandleError(w, err, http.StatusUnauthorized) { - return - } - - ah.WriteJSON(w, r, resp) -} - -// registerUser registers a user and responds with a precheck -// so the front-end can decide the login method -func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { - - if !ah.CheckFeature(model.SSO) { - ah.APIHandler.Register(w, r) - return - } - - ctx := context.Background() - var req *baseauth.RegisterRequest - - defer r.Body.Close() - requestBody, err := io.ReadAll(r.Body) - if err != nil { - zap.L().Error("received no input in api", zap.Error(err)) - RespondError(w, model.BadRequest(err), nil) - return - } - - err = json.Unmarshal(requestBody, &req) - - if err != nil { - zap.L().Error("received invalid user registration request", zap.Error(err)) - RespondError(w, model.BadRequest(fmt.Errorf("failed to register user")), nil) - return - } - - // get invite object - invite, err := baseauth.ValidateInvite(ctx, req) - if err != nil { - zap.L().Error("failed to validate invite token", zap.Error(err)) - RespondError(w, model.BadRequest(err), nil) - return - } - - if invite == nil { - zap.L().Error("failed to validate invite token: it is either empty or invalid", zap.Error(err)) - RespondError(w, model.BadRequest(basemodel.ErrSignupFailed{}), nil) - return - } - - // get auth domain from email domain - domain, apierr := ah.AppDao().GetDomainByEmail(ctx, invite.Email) - if apierr != nil { - zap.L().Error("failed to get domain from email", zap.Error(apierr)) - RespondError(w, model.InternalError(basemodel.ErrSignupFailed{}), nil) - } - - precheckResp := &basemodel.PrecheckResponse{ - SSO: false, - IsUser: false, - } - - if domain != nil && domain.SsoEnabled { - // sso is enabled, create user and respond precheck data - user, apierr := baseauth.RegisterInvitedUser(ctx, req, true) - if apierr != nil { - RespondError(w, apierr, nil) - return - } - - var precheckError basemodel.BaseApiError - - precheckResp, precheckError = ah.AppDao().PrecheckLogin(ctx, user.Email, req.SourceUrl) - if precheckError != nil { - RespondError(w, precheckError, precheckResp) - } - - } else { - // no-sso, validate password - if err := baseauth.ValidatePassword(req.Password); err != nil { - RespondError(w, model.InternalError(fmt.Errorf("password is not in a valid format")), nil) - return - } - - _, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization, ah.QuickFilterModule) - if !registerError.IsNil() { - RespondError(w, apierr, nil) - return - } - - precheckResp.IsUser = true - } - - ah.Respond(w, precheckResp) -} - -// getInvite returns the invite object details for the given invite token. We do not need to -// protect this API because invite token itself is meant to be private. -func (ah *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { - token := mux.Vars(r)["token"] - sourceUrl := r.URL.Query().Get("ref") - - inviteObject, err := baseauth.GetInvite(r.Context(), token, ah.Signoz.Modules.Organization) - if err != nil { - RespondError(w, model.BadRequest(err), nil) - return - } - - resp := model.GettableInvitation{ - InvitationResponseObject: inviteObject, - } - - precheck, apierr := ah.AppDao().PrecheckLogin(r.Context(), inviteObject.Email, sourceUrl) - resp.Precheck = precheck - - if apierr != nil { - RespondError(w, apierr, resp) - } - - ah.WriteJSON(w, r, resp) -} - -// PrecheckLogin enables browser login page to display appropriate -// login methods -func (ah *APIHandler) precheckLogin(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - - email := r.URL.Query().Get("email") - sourceUrl := r.URL.Query().Get("ref") - - resp, apierr := ah.AppDao().PrecheckLogin(ctx, email, sourceUrl) - if apierr != nil { - RespondError(w, apierr, resp) - } - - ah.Respond(w, resp) + ah.Signoz.Handlers.User.Login(w, r) + return } func handleSsoError(w http.ResponseWriter, r *http.Request, redirectURL string) { @@ -252,7 +102,7 @@ func (ah *APIHandler) receiveGoogleAuth(w http.ResponseWriter, r *http.Request) return } - nextPage, err := ah.AppDao().PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT) + nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, identity.Email, ah.opts.JWT) if err != nil { zap.L().Error("[receiveGoogleAuth] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err)) handleSsoError(w, r, redirectUri) @@ -330,7 +180,7 @@ func (ah *APIHandler) receiveSAML(w http.ResponseWriter, r *http.Request) { return } - nextPage, err := ah.AppDao().PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT) + nextPage, err := ah.Signoz.Modules.User.PrepareSsoRedirect(ctx, redirectUri, email, ah.opts.JWT) if err != nil { zap.L().Error("[receiveSAML] failed to generate redirect URI after successful login ", zap.String("domain", domain.String()), zap.Error(err)) handleSsoError(w, r, redirectUri) diff --git a/ee/query-service/app/api/cloudIntegrations.go b/ee/query-service/app/api/cloudIntegrations.go index 5d00741b6a28..8f451608faea 100644 --- a/ee/query-service/app/api/cloudIntegrations.go +++ b/ee/query-service/app/api/cloudIntegrations.go @@ -12,8 +12,8 @@ import ( "github.com/SigNoz/signoz/ee/query-service/constants" eeTypes "github.com/SigNoz/signoz/ee/types" + "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/http/render" - "github.com/SigNoz/signoz/pkg/query-service/auth" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" @@ -123,7 +123,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId )) } for _, p := range allPats { - if p.UserID == integrationUser.ID && p.Name == integrationPATName { + if p.UserID == integrationUser.ID.String() && p.Name == integrationPATName { return p.Token, nil } } @@ -135,8 +135,8 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId newPAT := eeTypes.NewGettablePAT( integrationPATName, - authtypes.RoleViewer.String(), - integrationUser.ID, + types.RoleViewer.String(), + integrationUser.ID.String(), 0, ) integrationPAT, err := ah.AppDao().CreatePAT(ctx, orgId, newPAT) @@ -154,10 +154,9 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser( cloudIntegrationUser := fmt.Sprintf("%s-integration", cloudProvider) email := fmt.Sprintf("%s@signoz.io", cloudIntegrationUser) - // TODO(nitya): there should be orgId here - integrationUserResult, apiErr := ah.AppDao().GetUserByEmail(ctx, email) - if apiErr != nil { - return nil, basemodel.WrapApiError(apiErr, "couldn't look for integration user") + integrationUserResult, err := ah.Signoz.Modules.User.GetUserByEmailInOrg(ctx, orgId, email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, basemodel.NotFoundError(fmt.Errorf("couldn't look for integration user: %w", err)) } if integrationUserResult != nil { @@ -169,29 +168,18 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser( zap.String("cloudProvider", cloudProvider), ) - newUser := &types.User{ - ID: uuid.New().String(), - Name: cloudIntegrationUser, - Email: email, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - }, - OrgID: orgId, - } - - newUser.Role = authtypes.RoleViewer.String() - - passwordHash, err := auth.PasswordHash(uuid.NewString()) + newUser, err := types.NewUser(cloudIntegrationUser, email, types.RoleViewer.String(), orgId) if err != nil { return nil, basemodel.InternalError(fmt.Errorf( - "couldn't hash random password for cloud integration user: %w", err, + "couldn't create cloud integration user: %w", err, )) } - newUser.Password = passwordHash - integrationUser, apiErr := ah.AppDao().CreateUser(ctx, newUser, false) - if apiErr != nil { - return nil, basemodel.WrapApiError(apiErr, "couldn't create cloud integration user") + password, err := types.NewFactorPassword(uuid.NewString()) + + integrationUser, err := ah.Signoz.Modules.User.CreateUserWithPassword(ctx, newUser, password) + if err != nil { + return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err)) } return integrationUser, nil diff --git a/ee/query-service/app/api/domains.go b/ee/query-service/app/api/domains.go index 6e99183ffec4..770c2048f98f 100644 --- a/ee/query-service/app/api/domains.go +++ b/ee/query-service/app/api/domains.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/ee/types" + "github.com/SigNoz/signoz/pkg/types" "github.com/google/uuid" "github.com/gorilla/mux" ) diff --git a/ee/query-service/app/api/pat.go b/ee/query-service/app/api/pat.go index 185dba8ff569..6fc736110d64 100644 --- a/ee/query-service/app/api/pat.go +++ b/ee/query-service/app/api/pat.go @@ -56,7 +56,7 @@ func (ah *APIHandler) createPAT(w http.ResponseWriter, r *http.Request) { } func validatePATRequest(req eeTypes.GettablePAT) error { - _, err := authtypes.NewRole(req.Role) + _, err := types.NewRole(req.Role) if err != nil { return err } @@ -93,16 +93,16 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) { } //get the pat - existingPAT, paterr := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id) - if paterr != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, paterr.Error())) + existingPAT, err := ah.AppDao().GetPATByID(r.Context(), claims.OrgID, id) + if err != nil { + render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())) return } // get the user - createdByUser, usererr := ah.AppDao().GetUser(r.Context(), existingPAT.UserID) - if usererr != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, usererr.Error())) + createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID) + if err != nil { + render.Error(w, err) return } @@ -119,7 +119,6 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) { req.UpdatedByUserID = claims.UserID req.UpdatedAt = time.Now() - zap.L().Info("Got Update PAT request", zap.Any("pat", req)) var apierr basemodel.BaseApiError if apierr = ah.AppDao().UpdatePAT(r.Context(), claims.OrgID, req, id); apierr != nil { RespondError(w, apierr, nil) @@ -167,9 +166,9 @@ func (ah *APIHandler) revokePAT(w http.ResponseWriter, r *http.Request) { } // get the user - createdByUser, usererr := ah.AppDao().GetUser(r.Context(), existingPAT.UserID) - if usererr != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, usererr.Error())) + createdByUser, err := ah.Signoz.Modules.User.GetUserByID(r.Context(), claims.OrgID, existingPAT.UserID) + if err != nil { + render.Error(w, err) return } diff --git a/ee/query-service/app/db/reader.go b/ee/query-service/app/db/reader.go index 8ec0eb17ab58..c518f5042d32 100644 --- a/ee/query-service/app/db/reader.go +++ b/ee/query-service/app/db/reader.go @@ -33,3 +33,7 @@ func NewDataConnector( ClickHouseReader: chReader, } } + +func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore { + return r.appdb +} diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 39fc3406ae60..b5c154ad10c7 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -195,6 +195,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } telemetry.GetInstance().SetReader(reader) + telemetry.GetInstance().SetSqlStore(serverOptions.SigNoz.SQLStore) telemetry.GetInstance().SetSaasOperator(constants.SaasSegmentKey) fluxInterval, err := time.ParseDuration(serverOptions.FluxInterval) diff --git a/ee/query-service/dao/interface.go b/ee/query-service/dao/interface.go index 32e25132453b..3bde9d3eafea 100644 --- a/ee/query-service/dao/interface.go +++ b/ee/query-service/dao/interface.go @@ -4,11 +4,11 @@ import ( "context" "net/url" - "github.com/SigNoz/signoz/ee/types" + eeTypes "github.com/SigNoz/signoz/ee/types" basedao "github.com/SigNoz/signoz/pkg/query-service/dao" baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" "github.com/google/uuid" "github.com/uptrace/bun" @@ -23,8 +23,6 @@ type ModelDao interface { DB() *bun.DB // auth methods - CanUsePassword(ctx context.Context, email string) (bool, basemodel.BaseApiError) - PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (redirectURL string, apierr basemodel.BaseApiError) GetDomainFromSsoResponse(ctx context.Context, relayState *url.URL) (*types.GettableOrgDomain, error) // org domain (auth domains) CRUD ops @@ -35,10 +33,10 @@ type ModelDao interface { DeleteDomain(ctx context.Context, id uuid.UUID) basemodel.BaseApiError GetDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, basemodel.BaseApiError) - CreatePAT(ctx context.Context, orgID string, p types.GettablePAT) (types.GettablePAT, basemodel.BaseApiError) - UpdatePAT(ctx context.Context, orgID string, p types.GettablePAT, id valuer.UUID) basemodel.BaseApiError - GetPAT(ctx context.Context, pat string) (*types.GettablePAT, basemodel.BaseApiError) - GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*types.GettablePAT, basemodel.BaseApiError) - ListPATs(ctx context.Context, orgID string) ([]types.GettablePAT, basemodel.BaseApiError) + CreatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT) (eeTypes.GettablePAT, basemodel.BaseApiError) + UpdatePAT(ctx context.Context, orgID string, p eeTypes.GettablePAT, id valuer.UUID) basemodel.BaseApiError + GetPAT(ctx context.Context, pat string) (*eeTypes.GettablePAT, basemodel.BaseApiError) + GetPATByID(ctx context.Context, orgID string, id valuer.UUID) (*eeTypes.GettablePAT, basemodel.BaseApiError) + ListPATs(ctx context.Context, orgID string) ([]eeTypes.GettablePAT, basemodel.BaseApiError) RevokePAT(ctx context.Context, orgID string, id valuer.UUID, userID string) basemodel.BaseApiError } diff --git a/ee/query-service/dao/sqlite/auth.go b/ee/query-service/dao/sqlite/auth.go deleted file mode 100644 index 7e4af543aa18..000000000000 --- a/ee/query-service/dao/sqlite/auth.go +++ /dev/null @@ -1,191 +0,0 @@ -package sqlite - -import ( - "context" - "fmt" - "net/url" - "time" - - "github.com/SigNoz/signoz/ee/query-service/constants" - "github.com/SigNoz/signoz/ee/query-service/model" - baseauth "github.com/SigNoz/signoz/pkg/query-service/auth" - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/query-service/utils" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/google/uuid" - "go.uber.org/zap" -) - -func (m *modelDao) createUserForSAMLRequest(ctx context.Context, email string) (*types.User, basemodel.BaseApiError) { - // get auth domain from email domain - domain, apierr := m.GetDomainByEmail(ctx, email) - if apierr != nil { - zap.L().Error("failed to get domain from email", zap.Error(apierr)) - return nil, model.InternalErrorStr("failed to get domain from email") - } - if domain == nil { - zap.L().Error("email domain does not match any authenticated domain", zap.String("email", email)) - return nil, model.InternalErrorStr("email domain does not match any authenticated domain") - } - - hash, err := baseauth.PasswordHash(utils.GeneratePassowrd()) - if err != nil { - zap.L().Error("failed to generate password hash when registering a user via SSO redirect", zap.Error(err)) - return nil, model.InternalErrorStr("failed to generate password hash") - } - - user := &types.User{ - ID: uuid.New().String(), - Name: "", - Email: email, - Password: hash, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - }, - ProfilePictureURL: "", // Currently unused - Role: authtypes.RoleViewer.String(), - OrgID: domain.OrgID, - } - - user, apiErr := m.CreateUser(ctx, user, false) - if apiErr != nil { - zap.L().Error("CreateUser failed", zap.Error(apiErr)) - return nil, apiErr - } - - return user, nil - -} - -// PrepareSsoRedirect prepares redirect page link after SSO response -// is successfully parsed (i.e. valid email is available) -func (m *modelDao) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (redirectURL string, apierr basemodel.BaseApiError) { - - userPayload, apierr := m.GetUserByEmail(ctx, email) - if !apierr.IsNil() { - zap.L().Error("failed to get user with email received from auth provider", zap.String("error", apierr.Error())) - return "", model.BadRequestStr("invalid user email received from the auth provider") - } - - user := &types.User{} - - if userPayload == nil { - newUser, apiErr := m.createUserForSAMLRequest(ctx, email) - user = newUser - if apiErr != nil { - zap.L().Error("failed to create user with email received from auth provider", zap.Error(apiErr)) - return "", apiErr - } - } else { - user = &userPayload.User - } - - tokenStore, err := baseauth.GenerateJWTForUser(user, jwt) - if err != nil { - zap.L().Error("failed to generate token for SSO login user", zap.Error(err)) - return "", model.InternalErrorStr("failed to generate token for the user") - } - - return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s", - redirectUri, - tokenStore.AccessJwt, - user.ID, - tokenStore.RefreshJwt), nil -} - -func (m *modelDao) CanUsePassword(ctx context.Context, email string) (bool, basemodel.BaseApiError) { - domain, apierr := m.GetDomainByEmail(ctx, email) - if apierr != nil { - return false, apierr - } - - if domain != nil && domain.SsoEnabled { - // sso is enabled, check if the user has admin role - userPayload, baseapierr := m.GetUserByEmail(ctx, email) - - if baseapierr != nil || userPayload == nil { - return false, baseapierr - } - - if userPayload.Role != authtypes.RoleAdmin.String() { - return false, model.BadRequest(fmt.Errorf("auth method not supported")) - } - - } - - return true, nil -} - -// PrecheckLogin is called when the login or signup page is loaded -// to check sso login is to be prompted -func (m *modelDao) PrecheckLogin(ctx context.Context, email, sourceUrl string) (*basemodel.PrecheckResponse, basemodel.BaseApiError) { - - // assume user is valid unless proven otherwise - resp := &basemodel.PrecheckResponse{IsUser: true, CanSelfRegister: false} - - // check if email is a valid user - userPayload, baseApiErr := m.GetUserByEmail(ctx, email) - if baseApiErr != nil { - return resp, baseApiErr - } - - if userPayload == nil { - resp.IsUser = false - } - - ssoAvailable := true - err := m.checkFeature(model.SSO) - if err != nil { - switch err.(type) { - case basemodel.ErrFeatureUnavailable: - // do nothing, just skip sso - ssoAvailable = false - default: - zap.L().Error("feature check failed", zap.String("featureKey", model.SSO), zap.Error(err)) - return resp, model.BadRequestStr(err.Error()) - } - } - - if ssoAvailable { - - resp.IsUser = true - - // find domain from email - orgDomain, apierr := m.GetDomainByEmail(ctx, email) - if apierr != nil { - zap.L().Error("failed to get org domain from email", zap.String("email", email), zap.Error(apierr.ToError())) - return resp, apierr - } - - if orgDomain != nil && orgDomain.SsoEnabled { - // saml is enabled for this domain, lets prepare sso url - - if sourceUrl == "" { - sourceUrl = constants.GetDefaultSiteURL() - } - - // parse source url that generated the login request - var err error - escapedUrl, _ := url.QueryUnescape(sourceUrl) - siteUrl, err := url.Parse(escapedUrl) - if err != nil { - zap.L().Error("failed to parse referer", zap.Error(err)) - return resp, model.InternalError(fmt.Errorf("failed to generate login request")) - } - - // build Idp URL that will authenticat the user - // the front-end will redirect user to this url - resp.SsoUrl, err = orgDomain.BuildSsoUrl(siteUrl) - - if err != nil { - zap.L().Error("failed to prepare saml request for domain", zap.String("domain", orgDomain.Name), zap.Error(err)) - return resp, model.InternalError(err) - } - - // set SSO to true, as the url is generated correctly - resp.SSO = true - } - } - return resp, nil -} diff --git a/ee/query-service/dao/sqlite/domain.go b/ee/query-service/dao/sqlite/domain.go index 1fe63c995e91..fb2bd109a8c4 100644 --- a/ee/query-service/dao/sqlite/domain.go +++ b/ee/query-service/dao/sqlite/domain.go @@ -10,8 +10,8 @@ import ( "time" "github.com/SigNoz/signoz/ee/query-service/model" - "github.com/SigNoz/signoz/ee/types" basemodel "github.com/SigNoz/signoz/pkg/query-service/model" + "github.com/SigNoz/signoz/pkg/types" ossTypes "github.com/SigNoz/signoz/pkg/types" "github.com/google/uuid" "go.uber.org/zap" @@ -44,7 +44,7 @@ func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url } domain, err = m.GetDomain(ctx, domainId) - if (err != nil) || domain == nil { + if err != nil { zap.L().Error("failed to find domain from domainId received in IdP response", zap.Error(err)) return nil, fmt.Errorf("invalid credentials") } @@ -54,7 +54,7 @@ func (m *modelDao) GetDomainFromSsoResponse(ctx context.Context, relayState *url domainFromDB, err := m.GetDomainByName(ctx, domainNameStr) domain = domainFromDB - if (err != nil) || domain == nil { + if err != nil { zap.L().Error("failed to find domain from domainName received in IdP response", zap.Error(err)) return nil, fmt.Errorf("invalid credentials") } diff --git a/ee/query-service/dao/sqlite/modelDao.go b/ee/query-service/dao/sqlite/modelDao.go index f50c5b16bb63..fdf73f603992 100644 --- a/ee/query-service/dao/sqlite/modelDao.go +++ b/ee/query-service/dao/sqlite/modelDao.go @@ -3,6 +3,8 @@ package sqlite import ( "fmt" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" basedao "github.com/SigNoz/signoz/pkg/query-service/dao" basedsql "github.com/SigNoz/signoz/pkg/query-service/dao/sqlite" baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces" @@ -12,7 +14,8 @@ import ( type modelDao struct { *basedsql.ModelDaoSqlite - flags baseint.FeatureLookup + flags baseint.FeatureLookup + userModule user.Module } // SetFlagProvider sets the feature lookup provider @@ -37,7 +40,8 @@ func InitDB(sqlStore sqlstore.SQLStore) (*modelDao, error) { } // set package variable so dependent base methods (e.g. AuthCache) will work basedao.SetDB(dao) - m := &modelDao{ModelDaoSqlite: dao} + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + m := &modelDao{ModelDaoSqlite: dao, userModule: userModule} return m, nil } diff --git a/ee/query-service/dao/sqlite/pat.go b/ee/query-service/dao/sqlite/pat.go index f60f05d14fae..e270f5903d28 100644 --- a/ee/query-service/dao/sqlite/pat.go +++ b/ee/query-service/dao/sqlite/pat.go @@ -25,7 +25,7 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable return types.GettablePAT{}, model.InternalError(fmt.Errorf("PAT insertion failed")) } - createdByUser, _ := m.GetUser(ctx, p.UserID) + createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, p.UserID) if createdByUser == nil { p.CreatedByUser = types.PatUser{ NotFound: true, @@ -33,14 +33,15 @@ func (m *modelDao) CreatePAT(ctx context.Context, orgID string, p types.Gettable } else { p.CreatedByUser = types.PatUser{ User: ossTypes.User{ - ID: createdByUser.ID, - Name: createdByUser.Name, - Email: createdByUser.Email, + Identifiable: ossTypes.Identifiable{ + ID: createdByUser.ID, + }, + DisplayName: createdByUser.DisplayName, + Email: createdByUser.Email, TimeAuditable: ossTypes.TimeAuditable{ CreatedAt: createdByUser.CreatedAt, UpdatedAt: createdByUser.UpdatedAt, }, - ProfilePictureURL: createdByUser.ProfilePictureURL, }, NotFound: false, } @@ -82,7 +83,7 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable StorablePersonalAccessToken: pats[i], } - createdByUser, _ := m.GetUser(ctx, pats[i].UserID) + createdByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UserID) if createdByUser == nil { patWithUser.CreatedByUser = types.PatUser{ NotFound: true, @@ -90,20 +91,21 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable } else { patWithUser.CreatedByUser = types.PatUser{ User: ossTypes.User{ - ID: createdByUser.ID, - Name: createdByUser.Name, - Email: createdByUser.Email, + Identifiable: ossTypes.Identifiable{ + ID: createdByUser.ID, + }, + DisplayName: createdByUser.DisplayName, + Email: createdByUser.Email, TimeAuditable: ossTypes.TimeAuditable{ CreatedAt: createdByUser.CreatedAt, UpdatedAt: createdByUser.UpdatedAt, }, - ProfilePictureURL: createdByUser.ProfilePictureURL, }, NotFound: false, } } - updatedByUser, _ := m.GetUser(ctx, pats[i].UpdatedByUserID) + updatedByUser, _ := m.userModule.GetUserByID(ctx, orgID, pats[i].UpdatedByUserID) if updatedByUser == nil { patWithUser.UpdatedByUser = types.PatUser{ NotFound: true, @@ -111,14 +113,15 @@ func (m *modelDao) ListPATs(ctx context.Context, orgID string) ([]types.Gettable } else { patWithUser.UpdatedByUser = types.PatUser{ User: ossTypes.User{ - ID: updatedByUser.ID, - Name: updatedByUser.Name, - Email: updatedByUser.Email, + Identifiable: ossTypes.Identifiable{ + ID: updatedByUser.ID, + }, + DisplayName: updatedByUser.DisplayName, + Email: updatedByUser.Email, TimeAuditable: ossTypes.TimeAuditable{ CreatedAt: updatedByUser.CreatedAt, UpdatedAt: updatedByUser.UpdatedAt, }, - ProfilePictureURL: updatedByUser.ProfilePictureURL, }, NotFound: false, } diff --git a/ee/query-service/main.go b/ee/query-service/main.go index 716c9ee333f8..22b307dc123f 100644 --- a/ee/query-service/main.go +++ b/ee/query-service/main.go @@ -6,6 +6,7 @@ import ( "os" "time" + eeuserimpl "github.com/SigNoz/signoz/ee/modules/user/impluser" "github.com/SigNoz/signoz/ee/query-service/app" "github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore" "github.com/SigNoz/signoz/ee/zeus" @@ -13,8 +14,10 @@ import ( "github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/fileprovider" + "github.com/SigNoz/signoz/pkg/modules/user" baseconst "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" + "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" @@ -118,6 +121,12 @@ func main() { signoz.NewWebProviderFactories(), sqlStoreFactories, signoz.NewTelemetryStoreProviderFactories(), + func(sqlstore sqlstore.SQLStore) user.Module { + return eeuserimpl.NewModule(eeuserimpl.NewStore(sqlstore)) + }, + func(userModule user.Module) user.Handler { + return eeuserimpl.NewHandler(userModule) + }, ) if err != nil { zap.L().Fatal("Failed to create signoz", zap.Error(err)) diff --git a/ee/query-service/model/auth.go b/ee/query-service/model/auth.go deleted file mode 100644 index fd7ecad9d0ea..000000000000 --- a/ee/query-service/model/auth.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -import ( - basemodel "github.com/SigNoz/signoz/pkg/query-service/model" -) - -// GettableInvitation overrides base object and adds precheck into -// response -type GettableInvitation struct { - *basemodel.InvitationResponseObject - Precheck *basemodel.PrecheckResponse `json:"precheck"` -} diff --git a/ee/query-service/sso/model.go b/ee/query-service/sso/model.go deleted file mode 100644 index 3e5f103b754d..000000000000 --- a/ee/query-service/sso/model.go +++ /dev/null @@ -1,31 +0,0 @@ -package sso - -import ( - "net/http" -) - -// SSOIdentity contains details of user received from SSO provider -type SSOIdentity struct { - UserID string - Username string - PreferredUsername string - Email string - EmailVerified bool - ConnectorData []byte -} - -// OAuthCallbackProvider is an interface implemented by connectors which use an OAuth -// style redirect flow to determine user information. -type OAuthCallbackProvider interface { - // The initial URL user would be redirect to. - // OAuth2 implementations support various scopes but we only need profile and user as - // the roles are still being managed in SigNoz. - BuildAuthURL(state string) (string, error) - - // Handle the callback to the server (after login at oauth provider site) - // and return a email identity. - // At the moment we dont support auto signup flow (based on domain), so - // the full identity (including name, group etc) is not required outside of the - // connector - HandleCallback(r *http.Request) (identity *SSOIdentity, err error) -} diff --git a/ee/sqlstore/postgressqlstore/dialect.go b/ee/sqlstore/postgressqlstore/dialect.go index f7b44cf6870b..28786a4cb332 100644 --- a/ee/sqlstore/postgressqlstore/dialect.go +++ b/ee/sqlstore/postgressqlstore/dialect.go @@ -19,12 +19,14 @@ var ( var ( Org = "org" User = "user" + FactorPassword = "factor_password" CloudIntegration = "cloud_integration" ) var ( OrgReference = `("org_id") REFERENCES "organizations" ("id")` UserReference = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` + FactorPasswordReference = `("password_id") REFERENCES "factor_password" ("id")` CloudIntegrationReference = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` ) @@ -264,6 +266,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I fkReferences = append(fkReferences, OrgReference) } else if reference == User && !slices.Contains(fkReferences, UserReference) { fkReferences = append(fkReferences, UserReference) + } else if reference == FactorPassword && !slices.Contains(fkReferences, FactorPasswordReference) { + fkReferences = append(fkReferences, FactorPasswordReference) } else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) { fkReferences = append(fkReferences, CloudIntegrationReference) } diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go new file mode 100644 index 000000000000..eb1d506f7d91 --- /dev/null +++ b/pkg/modules/user/impluser/handler.go @@ -0,0 +1,472 @@ +package impluser + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/http/render" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/gorilla/mux" +) + +type handler struct { + module user.Module +} + +func NewHandler(module user.Module) user.Handler { + return &handler{module: module} +} + +func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := new(types.PostableAcceptInvite) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode user")) + return + } + + // SSO users might not have a password + if err := req.Validate(); err != nil { + render.Error(w, err) + return + } + + invite, err := h.module.GetInviteByToken(ctx, req.InviteToken) + if err != nil { + render.Error(w, err) + return + } + + if invite.Name == "" && req.DisplayName != "" { + invite.Name = req.DisplayName + } + + user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID) + if err != nil { + render.Error(w, err) + return + } + + password, err := types.NewFactorPassword(req.Password) + if err != nil { + render.Error(w, err) + return + } + + user, err = h.module.CreateUserWithPassword(ctx, user, password) + if err != nil { + render.Error(w, err) + return + } + + // delete the invite + if err := h.module.DeleteInvite(ctx, invite.OrgID, invite.ID); err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusCreated, user) +} + +func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + var req types.PostableInvite + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + _, err = h.module.CreateBulkInvite(ctx, claims.OrgID, claims.UserID, &types.PostableBulkInviteRequest{ + Invites: []types.PostableInvite{req}, + }) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, nil) + return +} + +func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(rw, err) + return + } + + var req types.PostableBulkInviteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(rw, err) + return + } + + // Validate that the request contains users + if len(req.Invites) == 0 { + render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "no invites provided for invitation")) + return + } + + _, err = h.module.CreateBulkInvite(ctx, claims.OrgID, claims.UserID, &req) + if err != nil { + render.Error(rw, err) + return + } + + render.Success(rw, http.StatusCreated, nil) + return +} + +func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + token := mux.Vars(r)["token"] + invite, err := h.module.GetInviteByToken(ctx, token) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, invite) + return +} + +func (h *handler) ListInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + invites, err := h.module.ListInvite(ctx, claims.OrgID) + if err != nil { + render.Error(w, err) + return + } + render.Success(w, http.StatusOK, invites) +} + +func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := mux.Vars(r)["id"] + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + uuid, err := valuer.NewUUID(id) + if err != nil { + render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid")) + return + } + + if err := h.module.DeleteInvite(ctx, claims.OrgID, uuid); err != nil { + render.Error(w, err) + return + } + render.Success(w, http.StatusNoContent, nil) +} +func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := mux.Vars(r)["id"] + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + user, err := h.module.GetUserByID(ctx, claims.OrgID, id) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, user) +} + +func (h *handler) ListUsers(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + users, err := h.module.ListUsers(ctx, claims.OrgID) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, users) +} + +func (h *handler) UpdateUser(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := mux.Vars(r)["id"] + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + var user types.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + render.Error(w, err) + return + } + + existingUser, err := h.module.GetUserByID(ctx, claims.OrgID, id) + if err != nil { + render.Error(w, err) + return + } + + // only displayName, role can be updated + if user.DisplayName == "" { + user.DisplayName = existingUser.DisplayName + } + + if user.Role == "" { + user.Role = existingUser.Role + } + + if user.Role != existingUser.Role && claims.Role != types.RoleAdmin { + render.Error(w, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")) + return + } + + // Make sure that the request is not demoting the last admin user. + // also an admin user can only change role of their own or other user + if user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin.String() { + adminUsers, err := h.module.GetUsersByRoleInOrg(ctx, claims.OrgID, types.RoleAdmin) + if err != nil { + render.Error(w, err) + return + } + + if len(adminUsers) == 1 { + render.Error(w, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot demote the last admin")) + return + } + } + + user.UpdatedAt = time.Now() + + updatedUser, err := h.module.UpdateUser(ctx, claims.OrgID, id, &user) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, updatedUser) +} + +func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := mux.Vars(r)["id"] + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + if err := h.module.DeleteUser(ctx, claims.OrgID, id); err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusNoContent, nil) +} + +func (h *handler) LoginPrecheck(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + email := r.URL.Query().Get("email") + sourceUrl := r.URL.Query().Get("ref") + orgID := r.URL.Query().Get("orgID") + + resp, err := h.module.LoginPrecheck(ctx, orgID, email, sourceUrl) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, resp) +} + +func (h *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + id := mux.Vars(r)["id"] + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + // check if the id lies in the same org as the claims + _, err = h.module.GetUserByID(ctx, claims.OrgID, id) + if err != nil { + render.Error(w, err) + return + } + + token, err := h.module.CreateResetPasswordToken(ctx, id) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, token) +} + +func (h *handler) ResetPassword(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := new(types.PostableResetPassword) + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + render.Error(w, err) + return + } + + entry, err := h.module.GetResetPassword(ctx, req.Token) + if err != nil { + render.Error(w, err) + return + } + + err = h.module.UpdatePasswordAndDeleteResetPasswordEntry(ctx, entry.PasswordID, req.Password) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, nil) +} + +func (h *handler) ChangePassword(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + var req types.ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(w, err) + return + } + + // get the current password + password, err := h.module.GetPasswordByUserID(ctx, req.UserId) + if err != nil { + render.Error(w, err) + return + } + + if !types.ComparePassword(password.Password, req.OldPassword) { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "old password is incorrect")) + return + } + + err = h.module.UpdatePassword(ctx, req.UserId, req.NewPassword) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, nil) +} + +func (h *handler) Login(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + var req types.PostableLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + render.Error(w, err) + return + } + + user, err := h.module.GetAuthenticatedUser(ctx, req.OrgID, req.Email, req.Password, req.RefreshToken) + if err != nil { + render.Error(w, err) + return + } + if user == nil { + render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email or password")) + return + } + + jwt, err := h.module.GetJWTForUser(ctx, user) + if err != nil { + render.Error(w, err) + return + } + + gettableLoginResponse := &types.GettableLoginResponse{ + GettableUserJwt: jwt, + UserID: user.ID.String(), + } + + render.Success(w, http.StatusOK, gettableLoginResponse) +} + +func (h *handler) GetCurrentUserFromJWT(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + claims, err := authtypes.ClaimsFromContext(ctx) + if err != nil { + render.Error(w, err) + return + } + + user, err := h.module.GetUserByID(ctx, claims.OrgID, claims.UserID) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusOK, user) + +} diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go new file mode 100644 index 000000000000..5cbdb28d74a6 --- /dev/null +++ b/pkg/modules/user/impluser/module.go @@ -0,0 +1,394 @@ +package impluser + +import ( + "bytes" + "context" + "fmt" + "os" + "slices" + "text/template" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/query-service/constants" + "github.com/SigNoz/signoz/pkg/query-service/telemetry" + smtpservice "github.com/SigNoz/signoz/pkg/query-service/utils/smtpService" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" + "go.uber.org/zap" +) + +type Module struct { + store types.UserStore + JWT *authtypes.JWT +} + +// This module is a WIP, don't take inspiration from this. +func NewModule(store types.UserStore) user.Module { + jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET") + jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour) + return &Module{store: store, JWT: jwt} +} + +// CreateBulk implements invite.Module. +func (m *Module) CreateBulkInvite(ctx context.Context, orgID, userID string, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) { + creator, err := m.GetUserByID(ctx, orgID, userID) + if err != nil { + return nil, err + } + + invites := make([]*types.Invite, 0, len(bulkInvites.Invites)) + + for _, invite := range bulkInvites.Invites { + // check if user exists + existingUser, err := m.GetUserByEmailInOrg(ctx, orgID, invite.Email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + if existingUser != nil { + return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email") + } + + // Check if an invite already exists + existingInvite, err := m.GetInviteByEmailInOrg(ctx, orgID, invite.Email) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + if existingInvite != nil { + return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email") + } + + role, err := types.NewRole(invite.Role.String()) + if err != nil { + return nil, err + } + + newInvite, err := types.NewInvite(orgID, role.String(), invite.Name, invite.Email) + if err != nil { + return nil, err + } + newInvite.InviteLink = fmt.Sprintf("%s/signup?token=%s", invite.FrontendBaseUrl, newInvite.Token) + invites = append(invites, newInvite) + } + + err = m.store.CreateBulkInvite(ctx, invites) + if err != nil { + return nil, err + } + + // send telemetry event + for i := 0; i < len(invites); i++ { + telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_SENT, map[string]interface{}{ + "invited user email": invites[i].Email, + }, creator.Email, true, false) + + // send email if SMTP is enabled + if os.Getenv("SMTP_ENABLED") == "true" && bulkInvites.Invites[i].FrontendBaseUrl != "" { + m.inviteEmail(&bulkInvites.Invites[i], creator.Email, creator.DisplayName, invites[i].Token) + } + } + + return invites, nil +} + +func (m *Module) inviteEmail(req *types.PostableInvite, creatorEmail, creatorName, token string) { + smtp := smtpservice.GetInstance() + data := types.InviteEmailData{ + CustomerName: req.Name, + InviterName: creatorName, + InviterEmail: creatorEmail, + Link: fmt.Sprintf("%s/signup?token=%s", req.FrontendBaseUrl, token), + } + + tmpl, err := template.ParseFiles(constants.InviteEmailTemplate) + if err != nil { + zap.L().Error("failed to send email", zap.Error(err)) + return + } + + var body bytes.Buffer + if err := tmpl.Execute(&body, data); err != nil { + zap.L().Error("failed to send email", zap.Error(err)) + return + } + + err = smtp.SendEmail( + req.Email, + creatorName+" has invited you to their team in SigNoz", + body.String(), + ) + if err != nil { + zap.L().Error("failed to send email", zap.Error(err)) + return + } +} + +func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { + return m.store.ListInvite(ctx, orgID) +} + +func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error { + return m.store.DeleteInvite(ctx, orgID, id) +} + +func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) { + return m.store.GetInviteByToken(ctx, token) +} + +func (m *Module) GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) { + return m.store.GetInviteByEmailInOrg(ctx, orgID, email) +} + +func (m *Module) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) { + + user, err := m.store.CreateUserWithPassword(ctx, user, password) + if err != nil { + return nil, err + } + + return user, nil +} + +func (m *Module) CreateUser(ctx context.Context, user *types.User) error { + return m.store.CreateUser(ctx, user) +} + +func (m *Module) GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) { + return m.store.GetUserByID(ctx, orgID, id) +} + +func (m *Module) GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) { + return m.store.GetUserByEmailInOrg(ctx, orgID, email) +} + +func (m *Module) GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) { + return m.store.GetUsersByEmail(ctx, email) +} + +func (m *Module) GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) { + return m.store.GetUsersByRoleInOrg(ctx, orgID, role) +} + +func (m *Module) ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) { + return m.store.ListUsers(ctx, orgID) +} + +func (m *Module) UpdateUser(ctx context.Context, orgID string, id string, user *types.User) (*types.User, error) { + return m.store.UpdateUser(ctx, orgID, id, user) +} + +func (m *Module) DeleteUser(ctx context.Context, orgID string, id string) error { + user, err := m.store.GetUserByID(ctx, orgID, id) + if err != nil { + return err + } + + if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email)) { + return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted") + } + + // don't allow to delete the last admin user + adminUsers, err := m.GetUsersByRoleInOrg(ctx, orgID, types.RoleAdmin) + if err != nil { + return err + } + + if len(adminUsers) == 1 && user.Role == types.RoleAdmin.String() { + return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin") + } + + return m.store.DeleteUser(ctx, orgID, user.ID.StringValue()) +} + +func (m *Module) CreateResetPasswordToken(ctx context.Context, userID string) (*types.ResetPasswordRequest, error) { + password, err := m.store.GetPasswordByUserID(ctx, userID) + if err != nil { + // if the user does not have a password, we need to create a new one + // this will happen for SSO users + if errors.Ast(err, errors.TypeNotFound) { + password, err = m.store.CreatePassword(ctx, &types.FactorPassword{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: types.TimeAuditable{ + CreatedAt: time.Now(), + }, + Password: valuer.GenerateUUID().String(), + UserID: userID, + }) + if err != nil { + return nil, err + } + } else { + return nil, err + } + } + + resetPasswordRequest, err := types.NewResetPasswordRequest(password.ID.StringValue()) + if err != nil { + return nil, err + } + + // check if a reset password token already exists for this user + existingRequest, err := m.store.GetResetPasswordByPasswordID(ctx, resetPasswordRequest.PasswordID) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + + if existingRequest != nil { + return existingRequest, nil + } + + err = m.store.CreateResetPasswordToken(ctx, resetPasswordRequest) + if err != nil { + return nil, err + } + + return resetPasswordRequest, nil +} + +func (m *Module) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) { + return m.store.GetPasswordByUserID(ctx, id) +} + +func (m *Module) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) { + return m.store.GetResetPassword(ctx, token) +} + +func (m *Module) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, passwordID string, password string) error { + hashedPassword, err := types.HashPassword(password) + if err != nil { + return err + } + + existingPassword, err := m.store.GetPasswordByID(ctx, passwordID) + if err != nil { + return err + } + + return m.store.UpdatePasswordAndDeleteResetPasswordEntry(ctx, existingPassword.UserID, hashedPassword) +} + +func (m *Module) UpdatePassword(ctx context.Context, userID string, password string) error { + hashedPassword, err := types.HashPassword(password) + if err != nil { + return err + } + return m.store.UpdatePassword(ctx, userID, hashedPassword) +} + +func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) { + if refreshToken != "" { + // parse the refresh token + claims, err := m.JWT.Claims(refreshToken) + if err != nil { + return nil, err + } + + user, err := m.store.GetUserByID(ctx, claims.OrgID, claims.UserID) + if err != nil { + return nil, err + } + return &user.User, nil + } + + var dbUser *types.User + + // when the orgID is provided + if orgID != "" { + user, err := m.store.GetUserByEmailInOrg(ctx, orgID, email) + if err != nil { + return nil, err + } + dbUser = &user.User + } + + // when the orgID is not provided we login if the user exists in just one org + user, err := m.store.GetUsersByEmail(ctx, email) + if err != nil { + return nil, err + } + if len(user) == 1 { + dbUser = &user[0].User + } else { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "please provide an orgID") + } + + existingPassword, err := m.store.GetPasswordByUserID(ctx, dbUser.ID.StringValue()) + if err != nil { + return nil, err + } + + if !types.ComparePassword(existingPassword.Password, password) { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid password") + } + + return dbUser, nil +} + +func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) { + // assume user is valid unless proven otherwise and assign default values for rest of the fields + resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false, SSO: false, SSOUrl: "", SSOError: ""} + + // check if email is a valid user + users, err := m.GetUsersByEmail(ctx, email) + if err != nil { + return nil, err + } + + if len(users) == 0 { + resp.IsUser = false + } + + if len(users) > 1 { + resp.SelectOrg = true + resp.Orgs = make([]string, len(users)) + for i, user := range users { + resp.Orgs[i] = user.OrgID + } + } + + return resp, nil +} + +func (m *Module) GetJWTForUser(ctx context.Context, user *types.User) (types.GettableUserJwt, error) { + role, err := types.NewRole(user.Role) + if err != nil { + return types.GettableUserJwt{}, err + } + + accessJwt, accessClaims, err := m.JWT.AccessToken(user.OrgID, user.ID.String(), user.Email, role) + if err != nil { + return types.GettableUserJwt{}, err + } + + refreshJwt, refreshClaims, err := m.JWT.RefreshToken(user.OrgID, user.ID.String(), user.Email, role) + if err != nil { + return types.GettableUserJwt{}, err + } + + return types.GettableUserJwt{ + AccessJwt: accessJwt, + RefreshJwt: refreshJwt, + AccessJwtExpiry: accessClaims.ExpiresAt.Unix(), + RefreshJwtExpiry: refreshClaims.ExpiresAt.Unix(), + }, nil +} + +func (m *Module) CreateUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) { + return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SAML login is not supported") +} + +func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) { + return "", errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") +} + +func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) { + return false, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") +} + +func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) { + return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported") +} diff --git a/pkg/modules/user/impluser/store.go b/pkg/modules/user/impluser/store.go new file mode 100644 index 000000000000..61a6dc88171c --- /dev/null +++ b/pkg/modules/user/impluser/store.go @@ -0,0 +1,476 @@ +package impluser + +import ( + "context" + "database/sql" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Store struct { + sqlstore sqlstore.SQLStore +} + +func NewStore(sqlstore sqlstore.SQLStore) types.UserStore { + return &Store{sqlstore: sqlstore} +} + +// CreateBulkInvite implements types.InviteStore. +func (s *Store) CreateBulkInvite(ctx context.Context, invites []*types.Invite) error { + _, err := s.sqlstore.BunDB().NewInsert(). + Model(&invites). + Exec(ctx) + + if err != nil { + return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrInviteAlreadyExists, "invite with email: %s already exists in org: %s", invites[0].Email, invites[0].OrgID) + } + return nil +} + +// Delete implements types.InviteStore. +func (s *Store) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error { + _, err := s.sqlstore.BunDB().NewDelete(). + Model(&types.Invite{}). + Where("org_id = ?", orgID). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with id: %s does not exist in org: %s", id.StringValue(), orgID) + } + return nil +} + +// GetInviteByEmailInOrg implements types.InviteStore. +func (s *Store) GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) { + invite := new(types.Invite) + err := s.sqlstore.BunDB().NewSelect(). + Model(invite). + Where("email = ?", email). + Where("org_id = ?", orgID). + Scan(ctx) + + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with email: %s does not exist in org: %s", email, orgID) + } + + return invite, nil +} + +func (s *Store) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) { + invite := new(types.Invite) + err := s.sqlstore.BunDB().NewSelect(). + Model(invite). + Where("token = ?", token). + Scan(ctx) + + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with token: %s does not exist", token) + } + + orgName, err := s.getOrgNameByID(ctx, invite.OrgID) + if err != nil { + return nil, err + } + + gettableInvite := &types.GettableInvite{ + Invite: *invite, + Organization: orgName, + } + + return gettableInvite, nil +} + +func (s *Store) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) { + invites := new([]*types.Invite) + err := s.sqlstore.BunDB().NewSelect(). + Model(invites). + Where("org_id = ?", orgID). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with org id: %s does not exist", orgID) + } + return *invites, nil +} + +func (s *Store) CreatePassword(ctx context.Context, password *types.FactorPassword) (*types.FactorPassword, error) { + _, err := s.sqlstore.BunDB().NewInsert(). + Model(password). + Exec(ctx) + + if err != nil { + return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with user id: %s already exists", password.UserID) + } + + return password, nil +} + +func (s *Store) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) { + tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") + } + + defer tx.Rollback() + + if _, err := tx.NewInsert(). + Model(user). + Exec(ctx); err != nil { + return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) + } + + password.UserID = user.ID.StringValue() + if _, err := tx.NewInsert(). + Model(password). + Exec(ctx); err != nil { + return nil, s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrPasswordAlreadyExists, "password with email: %s already exists in org: %s", user.Email, user.OrgID) + } + + err = tx.Commit() + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction") + } + + return user, nil +} + +func (s *Store) CreateUser(ctx context.Context, user *types.User) error { + _, err := s.sqlstore.BunDB().NewInsert(). + Model(user). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrUserAlreadyExists, "user with email: %s already exists in org: %s", user.Email, user.OrgID) + } + return nil +} + +func (s *Store) GetDefaultOrgID(ctx context.Context) (string, error) { + org := new(types.Organization) + err := s.sqlstore.BunDB().NewSelect(). + Model(org). + Limit(1). + Scan(ctx) + if err != nil { + return "", s.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "default org does not exist") + } + return org.ID.String(), nil +} + +// this is temporary function, we plan to remove this in the next PR. +func (s *Store) getOrgNameByID(ctx context.Context, orgID string) (string, error) { + org := new(types.Organization) + err := s.sqlstore.BunDB().NewSelect(). + Model(org). + Where("id = ?", orgID). + Scan(ctx) + if err != nil { + return "", s.sqlstore.WrapNotFoundErrf(err, types.ErrOrganizationNotFound, "org with id: %s does not exist", orgID) + } + return org.DisplayName, nil +} + +func (s *Store) GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) { + user := new(types.User) + err := s.sqlstore.BunDB().NewSelect(). + Model(user). + Where("org_id = ?", orgID). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) + } + + // remove this in next PR + orgName, err := s.getOrgNameByID(ctx, orgID) + if err != nil { + return nil, err + } + + return &types.GettableUser{User: *user, Organization: orgName}, nil +} + +func (s *Store) GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) { + user := new(types.User) + err := s.sqlstore.BunDB().NewSelect(). + Model(user). + Where("org_id = ?", orgID). + Where("email = ?", email). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist in org: %s", email, orgID) + } + + // remove this in next PR + orgName, err := s.getOrgNameByID(ctx, orgID) + if err != nil { + return nil, err + } + + return &types.GettableUser{User: *user, Organization: orgName}, nil +} + +func (s *Store) GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) { + users := new([]*types.User) + err := s.sqlstore.BunDB().NewSelect(). + Model(users). + Where("email = ?", email). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with email: %s does not exist", email) + } + + // remove this in next PR + usersWithOrg := []*types.GettableUser{} + for _, user := range *users { + orgName, err := s.getOrgNameByID(ctx, user.OrgID) + if err != nil { + return nil, err + } + usersWithOrg = append(usersWithOrg, &types.GettableUser{User: *user, Organization: orgName}) + } + return usersWithOrg, nil +} + +func (s *Store) GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) { + users := new([]*types.User) + err := s.sqlstore.BunDB().NewSelect(). + Model(users). + Where("org_id = ?", orgID). + Where("role = ?", role). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with role: %s does not exist in org: %s", role, orgID) + } + + // remove this in next PR + orgName, err := s.getOrgNameByID(ctx, orgID) + if err != nil { + return nil, err + } + usersWithOrg := []*types.GettableUser{} + for _, user := range *users { + usersWithOrg = append(usersWithOrg, &types.GettableUser{User: *user, Organization: orgName}) + } + return usersWithOrg, nil +} + +func (s *Store) UpdateUser(ctx context.Context, orgID string, id string, user *types.User) (*types.User, error) { + user.UpdatedAt = time.Now() + _, err := s.sqlstore.BunDB().NewUpdate(). + Model(user). + Column("display_name"). + Column("role"). + Column("updated_at"). + Where("id = ?", id). + Where("org_id = ?", orgID). + Exec(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "user with id: %s does not exist in org: %s", id, orgID) + } + return user, nil +} + +func (s *Store) ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) { + users := []*types.User{} + err := s.sqlstore.BunDB().NewSelect(). + Model(&users). + Where("org_id = ?", orgID). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrUserNotFound, "users with org id: %s does not exist", orgID) + } + + // remove this in next PR + orgName, err := s.getOrgNameByID(ctx, orgID) + if err != nil { + return nil, err + } + usersWithOrg := []*types.GettableUser{} + for _, user := range users { + usersWithOrg = append(usersWithOrg, &types.GettableUser{User: *user, Organization: orgName}) + } + return usersWithOrg, nil +} + +func (s *Store) DeleteUser(ctx context.Context, orgID string, id string) error { + + tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") + } + + defer tx.Rollback() + + // get the password id + + var password types.FactorPassword + err = tx.NewSelect(). + Model(&password). + Where("user_id = ?", id). + Scan(ctx) + if err != nil && err != sql.ErrNoRows { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete password") + } + + // delete reset password request + _, err = tx.NewDelete(). + Model(new(types.ResetPasswordRequest)). + Where("password_id = ?", password.ID.String()). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password request") + } + + // delete factor password + _, err = tx.NewDelete(). + Model(new(types.FactorPassword)). + Where("user_id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password") + } + + // delete user + _, err = tx.NewDelete(). + Model(new(types.User)). + Where("org_id = ?", orgID). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user") + } + + err = tx.Commit() + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction") + } + + return nil +} + +func (s *Store) CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *types.ResetPasswordRequest) error { + _, err := s.sqlstore.BunDB().NewInsert(). + Model(resetPasswordRequest). + Exec(ctx) + + if err != nil { + return s.sqlstore.WrapAlreadyExistsErrf(err, types.ErrResetPasswordTokenAlreadyExists, "reset password token with password id: %s already exists", resetPasswordRequest.PasswordID) + } + return nil +} + +func (s *Store) GetPasswordByID(ctx context.Context, id string) (*types.FactorPassword, error) { + password := new(types.FactorPassword) + err := s.sqlstore.BunDB().NewSelect(). + Model(password). + Where("id = ?", id). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with id: %s does not exist", id) + } + return password, nil +} + +func (s *Store) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) { + password := new(types.FactorPassword) + err := s.sqlstore.BunDB().NewSelect(). + Model(password). + Where("user_id = ?", id). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", id) + } + return password, nil +} + +func (s *Store) GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*types.ResetPasswordRequest, error) { + resetPasswordRequest := new(types.ResetPasswordRequest) + err := s.sqlstore.BunDB().NewSelect(). + Model(resetPasswordRequest). + Where("password_id = ?", passwordID). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", passwordID) + } + return resetPasswordRequest, nil +} + +func (s *Store) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) { + resetPasswordRequest := new(types.ResetPasswordRequest) + err := s.sqlstore.BunDB().NewSelect(). + Model(resetPasswordRequest). + Where("token = ?", token). + Scan(ctx) + if err != nil { + return nil, s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with token: %s does not exist", token) + } + return resetPasswordRequest, nil +} + +func (s *Store) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error { + tx, err := s.sqlstore.BunDB().BeginTx(ctx, nil) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction") + } + + defer tx.Rollback() + + factorPassword := &types.FactorPassword{ + UserID: userID, + Password: password, + TimeAuditable: types.TimeAuditable{ + UpdatedAt: time.Now(), + }, + } + _, err = tx.NewUpdate(). + Model(factorPassword). + Column("password"). + Column("updated_at"). + Where("user_id = ?", userID). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) + } + + _, err = tx.NewDelete(). + Model(&types.ResetPasswordRequest{}). + Where("password_id = ?", userID). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token with password id: %s does not exist", userID) + } + + err = tx.Commit() + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction") + } + + return nil +} + +func (s *Store) UpdatePassword(ctx context.Context, userID string, password string) error { + factorPassword := &types.FactorPassword{ + UserID: userID, + Password: password, + TimeAuditable: types.TimeAuditable{ + UpdatedAt: time.Now(), + }, + } + _, err := s.sqlstore.BunDB().NewUpdate(). + Model(factorPassword). + Column("password"). + Column("updated_at"). + Where("user_id = ?", userID). + Exec(ctx) + if err != nil { + return s.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password with user id: %s does not exist", userID) + } + return nil +} + +func (s *Store) GetDomainByName(ctx context.Context, name string) (*types.StorableOrgDomain, error) { + return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "not supported") +} diff --git a/pkg/modules/user/user.go b/pkg/modules/user/user.go new file mode 100644 index 000000000000..aad4e95c2404 --- /dev/null +++ b/pkg/modules/user/user.go @@ -0,0 +1,75 @@ +package user + +import ( + "context" + "net/http" + + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" +) + +type Module interface { + // invite + CreateBulkInvite(ctx context.Context, orgID, userID string, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) + ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) + DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error + GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) + GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) + + // user + CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) + CreateUser(ctx context.Context, user *types.User) error + GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) + GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) // public function + GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) + GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) + ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) + UpdateUser(ctx context.Context, orgID string, id string, user *types.User) (*types.User, error) + DeleteUser(ctx context.Context, orgID string, id string) error + + // login + GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) + GetJWTForUser(ctx context.Context, user *types.User) (types.GettableUserJwt, error) + CreateUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) + LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) + + // sso + PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) + CanUsePassword(ctx context.Context, email string) (bool, error) + + // password + CreateResetPasswordToken(ctx context.Context, userID string) (*types.ResetPasswordRequest, error) + GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) + GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) + UpdatePassword(ctx context.Context, userID string, password string) error + UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, passwordID string, password string) error + + // Auth Domain + GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) +} + +type Handler interface { + // invite + CreateInvite(http.ResponseWriter, *http.Request) + AcceptInvite(http.ResponseWriter, *http.Request) + GetInvite(http.ResponseWriter, *http.Request) // public function + ListInvite(http.ResponseWriter, *http.Request) + DeleteInvite(http.ResponseWriter, *http.Request) + CreateBulkInvite(http.ResponseWriter, *http.Request) + + GetUser(http.ResponseWriter, *http.Request) + GetCurrentUserFromJWT(http.ResponseWriter, *http.Request) + ListUsers(http.ResponseWriter, *http.Request) + UpdateUser(http.ResponseWriter, *http.Request) + DeleteUser(http.ResponseWriter, *http.Request) + + // Login + LoginPrecheck(http.ResponseWriter, *http.Request) + Login(http.ResponseWriter, *http.Request) + + // Reset Password + GetResetPasswordToken(http.ResponseWriter, *http.Request) + ResetPassword(http.ResponseWriter, *http.Request) + ChangePassword(http.ResponseWriter, *http.Request) +} diff --git a/pkg/query-service/agentConf/db.go b/pkg/query-service/agentConf/db.go index 9f02672598c8..f812e2dd6f36 100644 --- a/pkg/query-service/agentConf/db.go +++ b/pkg/query-service/agentConf/db.go @@ -28,7 +28,7 @@ func (r *Repo) GetConfigHistory( element_type, COALESCE(created_by, -1) as created_by, created_at, - COALESCE((SELECT NAME FROM users + COALESCE((SELECT display_name FROM users WHERE id = v.created_by), "unknown") created_by_name, active, is_valid, @@ -67,7 +67,7 @@ func (r *Repo) GetConfigVersion( element_type, COALESCE(created_by, -1) as created_by, created_at, - COALESCE((SELECT NAME FROM users + COALESCE((SELECT display_name FROM users WHERE id = v.created_by), "unknown") created_by_name, active, is_valid, @@ -100,7 +100,7 @@ func (r *Repo) GetLatestVersion( element_type, COALESCE(created_by, -1) as created_by, created_at, - COALESCE((SELECT NAME FROM users + COALESCE((SELECT display_name FROM users WHERE id = v.created_by), "unknown") created_by_name, active, is_valid, diff --git a/pkg/query-service/app/cloudintegrations/controller_test.go b/pkg/query-service/app/cloudintegrations/controller_test.go index 1c40cd1fb427..9c66d0530baf 100644 --- a/pkg/query-service/app/cloudintegrations/controller_test.go +++ b/pkg/query-service/app/cloudintegrations/controller_test.go @@ -6,11 +6,11 @@ import ( "github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" - "github.com/SigNoz/signoz/pkg/query-service/dao" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/query-service/utils" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -22,7 +22,8 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule) + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) // should be able to generate connection url for @@ -69,7 +70,8 @@ func TestAgentCheckIns(t *testing.T) { controller, err := NewController(sqlStore) require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule) + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) // An agent should be able to check in from a cloud account even @@ -156,7 +158,8 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule) + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) // Attempting to disconnect a non-existent account should return error @@ -175,7 +178,8 @@ func TestConfigureService(t *testing.T) { require.NoError(err) organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule) + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + user, apiErr := createTestUser(organizationModule, userModule) require.Nil(apiErr) // create a connected account @@ -290,7 +294,7 @@ func makeTestConnectedAccount(t *testing.T, orgId string, controller *Controller return acc } -func createTestUser(organizationModule organization.Module) (*types.User, *model.ApiError) { +func createTestUser(organizationModule organization.Module, userModule user.Module) (*types.User, *model.ApiError) { // Create a test user for auth ctx := context.Background() organization := types.NewOrganization("test") @@ -299,17 +303,18 @@ func createTestUser(organizationModule organization.Module) (*types.User, *model return nil, model.InternalError(err) } - userId := uuid.NewString() - return dao.DB().CreateUser( - ctx, - &types.User{ - ID: userId, - Name: "test", - Email: userId[:8] + "test@test.com", - Password: "test", - OrgID: organization.ID.StringValue(), - Role: authtypes.RoleAdmin.String(), - }, - true, - ) + random, err := utils.RandomHex(3) + if err != nil { + return nil, model.InternalError(err) + } + + user, err := types.NewUser("test", random+"test@test.com", types.RoleAdmin.String(), organization.ID.StringValue()) + if err != nil { + return nil, model.InternalError(err) + } + err = userModule.CreateUser(ctx, user) + if err != nil { + return nil, model.InternalError(err) + } + return user, nil } diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 11f9d73f4eb2..0d0d30d2827c 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -268,17 +268,20 @@ func NewAPIHandler(opts APIHandlerOpts) (*APIHandler, error) { } aH.queryBuilder = queryBuilder.NewQueryBuilder(builderOpts) - // check if at least one user is created - hasUsers, err := aH.appDao.GetUsersWithOpts(context.Background(), 1) - if err.Error() != "" { - // raise warning but no panic as this is a recoverable condition - zap.L().Warn("unexpected error while fetch user count while initializing base api handler", zap.Error(err)) + // TODO(nitya): remote this in later for multitenancy. + orgs, err := opts.Signoz.Modules.Organization.GetAll(context.Background()) + if err != nil { + zap.L().Warn("unexpected error while fetching orgs while initializing base api handler", zap.Error(err)) } - if len(hasUsers) != 0 { - // first user is already created, we can mark the app ready for general use. - // this means, we disable self-registration and expect new users - // to signup signoz through invite link only. - aH.SetupCompleted = true + // if the first org with the first user is created then the setup is complete. + if len(orgs) == 1 { + users, err := opts.Signoz.Modules.User.ListUsers(context.Background(), orgs[0].ID.String()) + if err != nil { + zap.L().Warn("unexpected error while fetch user count while initializing base api handler", zap.Error(err)) + } + if len(users) > 0 { + aH.SetupCompleted = true + } } aH.Upgrader = &websocket.Upgrader{ @@ -583,32 +586,29 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.QuickFilters.UpdateQuickFilters)).Methods(http.MethodPut) // === Authentication APIs === - router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.inviteUser)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.inviteUsers)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.getInvite)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/invite/{email}", am.AdminAccess(aH.revokeInvite)).Methods(http.MethodDelete) - router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.listPendingInvites)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.Signoz.Handlers.User.CreateInvite)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/invite/bulk", am.AdminAccess(aH.Signoz.Handlers.User.CreateBulkInvite)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/invite/{token}", am.OpenAccess(aH.Signoz.Handlers.User.GetInvite)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/invite/{id}", am.AdminAccess(aH.Signoz.Handlers.User.DeleteInvite)).Methods(http.MethodDelete) + router.HandleFunc("/api/v1/invite", am.AdminAccess(aH.Signoz.Handlers.User.ListInvite)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/invite/accept", am.OpenAccess(aH.Signoz.Handlers.User.AcceptInvite)).Methods(http.MethodPost) router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/login", am.OpenAccess(aH.loginUser)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(aH.precheckLogin)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/login", am.OpenAccess(aH.Signoz.Handlers.User.Login)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/loginPrecheck", am.OpenAccess(aH.Signoz.Handlers.User.LoginPrecheck)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/user", am.AdminAccess(aH.listUsers)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.getUser)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.editUser)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/user/{id}", am.AdminAccess(aH.deleteUser)).Methods(http.MethodDelete) - - router.HandleFunc("/api/v1/rbac/role/{id}", am.SelfAccess(aH.getRole)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/rbac/role/{id}", am.AdminAccess(aH.editRole)).Methods(http.MethodPut) - - router.HandleFunc("/api/v1/orgUsers/{id}", am.AdminAccess(aH.getOrgUsers)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/user", am.AdminAccess(aH.Signoz.Handlers.User.ListUsers)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/user/me", am.OpenAccess(aH.Signoz.Handlers.User.GetCurrentUserFromJWT)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.Signoz.Handlers.User.GetUser)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/user/{id}", am.SelfAccess(aH.Signoz.Handlers.User.UpdateUser)).Methods(http.MethodPut) + router.HandleFunc("/api/v1/user/{id}", am.AdminAccess(aH.Signoz.Handlers.User.DeleteUser)).Methods(http.MethodDelete) router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Get)).Methods(http.MethodGet) router.HandleFunc("/api/v2/orgs/me", am.AdminAccess(aH.Signoz.Handlers.Organization.Update)).Methods(http.MethodPut) - router.HandleFunc("/api/v1/getResetPasswordToken/{id}", am.AdminAccess(aH.getResetPasswordToken)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/resetPassword", am.OpenAccess(aH.resetPassword)).Methods(http.MethodPost) - router.HandleFunc("/api/v1/changePassword/{id}", am.SelfAccess(aH.changePassword)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/getResetPasswordToken/{id}", am.AdminAccess(aH.Signoz.Handlers.User.GetResetPasswordToken)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/resetPassword", am.OpenAccess(aH.Signoz.Handlers.User.ResetPassword)).Methods(http.MethodPost) + router.HandleFunc("/api/v1/changePassword/{id}", am.SelfAccess(aH.Signoz.Handlers.User.ChangePassword)).Methods(http.MethodPost) router.HandleFunc("/api/v3/licenses", am.ViewAccess(func(rw http.ResponseWriter, req *http.Request) { render.Success(rw, http.StatusOK, []any{}) @@ -2016,451 +2016,31 @@ func (aH *APIHandler) getHealth(w http.ResponseWriter, r *http.Request) { aH.WriteJSON(w, r, map[string]string{"status": "ok"}) } -// inviteUser is used to invite a user. It is used by an admin api. -func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) { - req, err := parseInviteRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - resp, err := auth.Invite(r.Context(), req) - if err != nil { - render.Error(w, err) - return - } - - aH.WriteJSON(w, r, resp) -} - -func (aH *APIHandler) inviteUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - req, err := parseInviteUsersRequest(r) - if err != nil { - RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil) - return - } - - response, err := auth.InviteUsers(ctx, req) - if err != nil { - RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) - return - } - // Check the response status and set the appropriate HTTP status code - if response.Status == "failure" { - w.WriteHeader(http.StatusBadRequest) // 400 Bad Request for failure - } else if response.Status == "partial_success" { - w.WriteHeader(http.StatusPartialContent) // 206 Partial Content - } else { - w.WriteHeader(http.StatusOK) // 200 OK for success - } - - aH.WriteJSON(w, r, response) -} - -// getInvite returns the invite object details for the given invite token. We do not need to -// protect this API because invite token itself is meant to be private. -func (aH *APIHandler) getInvite(w http.ResponseWriter, r *http.Request) { - token := mux.Vars(r)["token"] - - resp, err := auth.GetInvite(context.Background(), token, aH.Signoz.Modules.Organization) - if err != nil { - RespondError(w, &model.ApiError{Err: err, Typ: model.ErrorNotFound}, nil) - return - } - aH.WriteJSON(w, r, resp) -} - -// revokeInvite is used to revoke an invite. -func (aH *APIHandler) revokeInvite(w http.ResponseWriter, r *http.Request) { - email := mux.Vars(r)["email"] - - if err := auth.RevokeInvite(r.Context(), email); err != nil { - RespondError(w, &model.ApiError{Err: err, Typ: model.ErrorInternal}, nil) - return - } - aH.WriteJSON(w, r, map[string]string{"data": "invite revoked successfully"}) -} - -// listPendingInvites is used to list the pending invites. -func (aH *APIHandler) listPendingInvites(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - claims, errv2 := authtypes.ClaimsFromContext(ctx) - if errv2 != nil { - render.Error(w, errv2) - return - } - invites, err := dao.DB().GetInvites(ctx, claims.OrgID) - if err != nil { - RespondError(w, err, nil) - return - } - - // TODO(Ahsan): Querying org name based on orgId for each invite is not a good idea. Either - // we should include org name field in the invite table, or do a join query. - var resp []*model.InvitationResponseObject - for _, inv := range invites { - orgID, err := valuer.NewUUID(inv.OrgID) - if err != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "invalid org_id in the invite")) - } - org, err := aH.Signoz.Modules.Organization.Get(ctx, orgID) - if err != nil { - render.Error(w, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, err.Error())) - } - resp = append(resp, &model.InvitationResponseObject{ - Name: inv.Name, - Email: inv.Email, - Token: inv.Token, - CreatedAt: inv.CreatedAt.Unix(), - Role: inv.Role, - Organization: org.Name, - }) - } - aH.WriteJSON(w, r, resp) -} - -// Register extends registerUser for non-internal packages -func (aH *APIHandler) Register(w http.ResponseWriter, r *http.Request) { - aH.registerUser(w, r) -} - func (aH *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) { - req, err := parseRegisterRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { + if aH.SetupCompleted { + RespondError(w, &model.ApiError{Err: errors.New("self-registration is disabled"), Typ: model.ErrorBadData}, nil) return } - _, apiErr := auth.Register(context.Background(), req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization, aH.QuickFilterModule) + var req types.PostableRegisterOrgAndAdmin + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + RespondError(w, &model.ApiError{Err: err, Typ: model.ErrorBadData}, nil) + return + } + + _, apiErr := auth.Register(context.Background(), &req, aH.Signoz.Alertmanager, aH.Signoz.Modules.Organization, aH.Signoz.Modules.User, aH.QuickFilterModule) if apiErr != nil { RespondError(w, apiErr, nil) return } - if !aH.SetupCompleted { - // since the first user is now created, we can disable self-registration as - // from here onwards, we expect admin (owner) to invite other users. - aH.SetupCompleted = true - } + // since the first user is now created, we can disable self-registration as + // from here onwards, we expect admin (owner) to invite other users. + aH.SetupCompleted = true aH.Respond(w, nil) } -func (aH *APIHandler) precheckLogin(w http.ResponseWriter, r *http.Request) { - - email := r.URL.Query().Get("email") - sourceUrl := r.URL.Query().Get("ref") - - resp, apierr := aH.appDao.PrecheckLogin(context.Background(), email, sourceUrl) - if apierr != nil { - RespondError(w, apierr, resp) - return - } - - aH.Respond(w, resp) -} - -func (aH *APIHandler) loginUser(w http.ResponseWriter, r *http.Request) { - req, err := parseLoginRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - // c, err := r.Cookie("refresh-token") - // if err != nil { - // if err != http.ErrNoCookie { - // w.WriteHeader(http.StatusBadRequest) - // return - // } - // } - - // if c != nil { - // req.RefreshToken = c.Value - // } - - resp, err := auth.Login(context.Background(), req, aH.JWT) - if aH.HandleError(w, err, http.StatusUnauthorized) { - return - } - - // http.SetCookie(w, &http.Cookie{ - // Name: "refresh-token", - // Value: resp.RefreshJwt, - // Expires: time.Unix(resp.RefreshJwtExpiry, 0), - // HttpOnly: true, - // }) - - aH.WriteJSON(w, r, resp) -} - -func (aH *APIHandler) listUsers(w http.ResponseWriter, r *http.Request) { - users, err := dao.DB().GetUsers(context.Background()) - if err != nil { - zap.L().Error("[listUsers] Failed to query list of users", zap.Error(err)) - RespondError(w, err, nil) - return - } - // mask the password hash - for i := range users { - users[i].Password = "" - } - aH.WriteJSON(w, r, users) -} - -func (aH *APIHandler) getUser(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - ctx := context.Background() - user, err := dao.DB().GetUser(ctx, id) - if err != nil { - zap.L().Error("[getUser] Failed to query user", zap.Error(err)) - RespondError(w, err, "Failed to get user") - return - } - if user == nil { - RespondError(w, &model.ApiError{ - Typ: model.ErrorInternal, - Err: errors.New("user not found"), - }, nil) - return - } - - // No need to send password hash for the user object. - user.Password = "" - aH.WriteJSON(w, r, user) -} - -// editUser only changes the user's Name and ProfilePictureURL. It is intentionally designed -// to not support update of orgId, Password, createdAt for the sucurity reasons. -func (aH *APIHandler) editUser(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - update, err := parseUserRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - ctx := context.Background() - old, apiErr := dao.DB().GetUser(ctx, id) - if apiErr != nil { - zap.L().Error("[editUser] Failed to query user", zap.Error(err)) - RespondError(w, apiErr, nil) - return - } - - if len(update.Name) > 0 { - old.Name = update.Name - } - if len(update.ProfilePictureURL) > 0 { - old.ProfilePictureURL = update.ProfilePictureURL - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(old.Email)) { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "integration user cannot be updated")) - return - } - - _, apiErr = dao.DB().EditUser(ctx, &types.User{ - ID: old.ID, - Name: old.Name, - OrgID: old.OrgID, - Email: old.Email, - Password: old.Password, - TimeAuditable: types.TimeAuditable{ - CreatedAt: old.CreatedAt, - }, - ProfilePictureURL: old.ProfilePictureURL, - }) - if apiErr != nil { - RespondError(w, apiErr, nil) - return - } - aH.WriteJSON(w, r, map[string]string{"data": "user updated successfully"}) -} - -func (aH *APIHandler) deleteUser(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - // Query for the user's group, and the admin's group. If the user belongs to the admin group - // and is the last user then don't let the deletion happen. Otherwise, the system will become - // admin less and hence inaccessible. - ctx := context.Background() - user, apiErr := dao.DB().GetUser(ctx, id) - if apiErr != nil { - RespondError(w, apiErr, "Failed to get user's group") - return - } - - if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email)) { - render.Error(w, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "integration user cannot be updated")) - return - } - - if user == nil { - RespondError(w, &model.ApiError{ - Typ: model.ErrorNotFound, - Err: errors.New("no user found"), - }, nil) - return - } - - adminUsers, apiErr := dao.DB().GetUsersByRole(ctx, authtypes.RoleAdmin) - if apiErr != nil { - RespondError(w, apiErr, "Failed to get admin group users") - return - } - - if user.Role == authtypes.RoleAdmin.String() && len(adminUsers) == 1 { - RespondError(w, &model.ApiError{ - Typ: model.ErrorInternal, - Err: errors.New("cannot delete the last admin user")}, nil) - return - } - - err := dao.DB().DeleteUser(ctx, id) - if err != nil { - RespondError(w, err, "Failed to delete user") - return - } - - aH.WriteJSON(w, r, map[string]string{"data": "user deleted successfully"}) -} - -func (aH *APIHandler) getRole(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - user, err := dao.DB().GetUser(context.Background(), id) - if err != nil { - RespondError(w, err, "Failed to get user's group") - return - } - if user == nil { - RespondError(w, &model.ApiError{ - Typ: model.ErrorNotFound, - Err: errors.New("no user found"), - }, nil) - return - } - - aH.WriteJSON(w, r, &model.UserRole{UserId: id, GroupName: user.Role}) -} - -func (aH *APIHandler) editRole(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - - req, err := parseUserRoleRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - ctx := context.Background() - role, err := authtypes.NewRole(req.GroupName) - if err != nil { - RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: errors.New("invalid role")}, nil) - return - } - - user, apiErr := dao.DB().GetUser(ctx, id) - if apiErr != nil { - RespondError(w, apiErr, "Failed to fetch user group") - return - } - - // Make sure that the request is not demoting the last admin user. - if user.Role == authtypes.RoleAdmin.String() { - adminUsers, apiErr := dao.DB().GetUsersByRole(ctx, authtypes.RoleAdmin) - if apiErr != nil { - RespondError(w, apiErr, "Failed to fetch adminUsers") - return - } - - if len(adminUsers) == 1 { - RespondError(w, &model.ApiError{ - Err: errors.New("cannot demote the last admin"), - Typ: model.ErrorInternal}, nil) - return - } - } - - apiErr = dao.DB().UpdateUserRole(context.Background(), user.ID, role) - if apiErr != nil { - RespondError(w, apiErr, "Failed to add user to group") - return - } - aH.WriteJSON(w, r, map[string]string{"data": "user group updated successfully"}) -} - -func (aH *APIHandler) getOrgUsers(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - users, apiErr := dao.DB().GetUsersByOrg(context.Background(), id) - if apiErr != nil { - RespondError(w, apiErr, "Failed to fetch org users from the DB") - return - } - // mask the password hash - for i := range users { - users[i].Password = "" - } - aH.WriteJSON(w, r, users) -} - -func (aH *APIHandler) getResetPasswordToken(w http.ResponseWriter, r *http.Request) { - id := mux.Vars(r)["id"] - resp, err := auth.CreateResetPasswordToken(context.Background(), id) - if err != nil { - RespondError(w, &model.ApiError{ - Typ: model.ErrorInternal, - Err: err}, "Failed to create reset token entry in the DB") - return - } - aH.WriteJSON(w, r, resp) -} - -func (aH *APIHandler) resetPassword(w http.ResponseWriter, r *http.Request) { - req, err := parseResetPasswordRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - if err := auth.ResetPassword(context.Background(), req); err != nil { - zap.L().Error("resetPassword failed", zap.Error(err)) - if aH.HandleError(w, err, http.StatusInternalServerError) { - return - } - - } - aH.WriteJSON(w, r, map[string]string{"data": "password reset successfully"}) -} - -func (aH *APIHandler) changePassword(w http.ResponseWriter, r *http.Request) { - req, err := parseChangePasswordRequest(r) - if aH.HandleError(w, err, http.StatusBadRequest) { - return - } - - if apiErr := auth.ChangePassword(context.Background(), req); apiErr != nil { - RespondError(w, apiErr, nil) - return - - } - aH.WriteJSON(w, r, map[string]string{"data": "password changed successfully"}) -} - -// func (aH *APIHandler) getApplicationPercentiles(w http.ResponseWriter, r *http.Request) { -// // vars := mux.Vars(r) - -// query, err := parseApplicationPercentileRequest(r) -// if aH.HandleError(w, err, http.StatusBadRequest) { -// return -// } - -// result, err := aH.reader.GetApplicationPercentiles(context.Background(), query) -// if aH.HandleError(w, err, http.StatusBadRequest) { -// return -// } -// aH.WriteJSON(w, r, result) -// } - func (aH *APIHandler) HandleError(w http.ResponseWriter, err error, statusCode int) bool { if err == nil { return false diff --git a/pkg/query-service/app/integrations/manager_test.go b/pkg/query-service/app/integrations/manager_test.go index 689b60317ac8..f6e8d0a12a10 100644 --- a/pkg/query-service/app/integrations/manager_test.go +++ b/pkg/query-service/app/integrations/manager_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/require" ) @@ -16,7 +17,8 @@ func TestIntegrationLifecycle(t *testing.T) { ctx := context.Background() organizationModule := implorganization.NewModule(implorganization.NewStore(store)) - user, apiErr := createTestUser(organizationModule) + userModule := impluser.NewModule(impluser.NewStore(store)) + user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create test user: %v", apiErr) } diff --git a/pkg/query-service/app/integrations/test_utils.go b/pkg/query-service/app/integrations/test_utils.go index 11a67fbd9b24..94a2954e871b 100644 --- a/pkg/query-service/app/integrations/test_utils.go +++ b/pkg/query-service/app/integrations/test_utils.go @@ -6,16 +6,14 @@ import ( "testing" "github.com/SigNoz/signoz/pkg/modules/organization" - "github.com/SigNoz/signoz/pkg/query-service/dao" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/query-service/model" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/query-service/utils" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/types/pipelinetypes" ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes" - "github.com/google/uuid" ) func NewTestIntegrationsManager(t *testing.T) (*Manager, sqlstore.SQLStore) { @@ -32,7 +30,7 @@ func NewTestIntegrationsManager(t *testing.T) (*Manager, sqlstore.SQLStore) { }, testDB } -func createTestUser(organizationModule organization.Module) (*types.User, *model.ApiError) { +func createTestUser(organizationModule organization.Module, userModule user.Module) (*types.User, *model.ApiError) { // Create a test user for auth ctx := context.Background() organization := types.NewOrganization("test") @@ -41,19 +39,21 @@ func createTestUser(organizationModule organization.Module) (*types.User, *model return nil, model.InternalError(err) } - userId := uuid.NewString() - return dao.DB().CreateUser( - ctx, - &types.User{ - ID: userId, - Name: "test", - Email: userId[:8] + "test@test.com", - Password: "test", - OrgID: organization.ID.StringValue(), - Role: authtypes.RoleAdmin.String(), - }, - true, - ) + random, err := utils.RandomHex(3) + if err != nil { + return nil, model.InternalError(err) + } + + user, err := types.NewUser("test", random+"test@test.com", types.RoleAdmin.String(), organization.ID.StringValue()) + if err != nil { + return nil, model.InternalError(err) + } + + err = userModule.CreateUser(ctx, user) + if err != nil { + return nil, model.InternalError(err) + } + return user, nil } type TestAvailableIntegrationsRepo struct{} diff --git a/pkg/query-service/app/parser.go b/pkg/query-service/app/parser.go index c6907400cc12..a5b45347cdda 100644 --- a/pkg/query-service/app/parser.go +++ b/pkg/query-service/app/parser.go @@ -16,7 +16,6 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka" queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues" "github.com/SigNoz/signoz/pkg/query-service/app/integrations/thirdPartyApi" - "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/govaluate" "github.com/gorilla/mux" @@ -26,7 +25,6 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/app/metrics" "github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder" - "github.com/SigNoz/signoz/pkg/query-service/auth" "github.com/SigNoz/signoz/pkg/query-service/common" baseconstants "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/query-service/model" @@ -474,54 +472,6 @@ func parseGetTTL(r *http.Request) (*model.GetTTLParams, error) { return &model.GetTTLParams{Type: typeTTL}, nil } -func parseUserRequest(r *http.Request) (*types.User, error) { - var req types.User - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - return &req, nil -} - -func parseInviteRequest(r *http.Request) (*model.InviteRequest, error) { - var req model.InviteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - // Trim spaces from email - req.Email = strings.TrimSpace(req.Email) - return &req, nil -} - -func parseInviteUsersRequest(r *http.Request) (*model.BulkInviteRequest, error) { - var req model.BulkInviteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - - // Validate that the request contains users - if len(req.Users) == 0 { - return nil, fmt.Errorf("no users provided for invitation") - } - - // Trim spaces and validate each user - for i := range req.Users { - req.Users[i].Email = strings.TrimSpace(req.Users[i].Email) - if req.Users[i].Email == "" { - return nil, fmt.Errorf("email is required for each user") - } - if req.Users[i].FrontendBaseUrl == "" { - return nil, fmt.Errorf("frontendBaseUrl is required for each user") - } - - _, err := authtypes.NewRole(req.Users[i].Role) - if err != nil { - return nil, fmt.Errorf("invalid role for user: %s", req.Users[i].Email) - } - } - - return &req, nil -} - func parseSetApdexScoreRequest(r *http.Request) (*types.ApdexSettings, error) { var req types.ApdexSettings if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -530,72 +480,6 @@ func parseSetApdexScoreRequest(r *http.Request) (*types.ApdexSettings, error) { return &req, nil } -func parseRegisterRequest(r *http.Request) (*auth.RegisterRequest, error) { - var req auth.RegisterRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - - if err := auth.ValidatePassword(req.Password); err != nil { - return nil, err - } - - return &req, nil -} - -func parseLoginRequest(r *http.Request) (*model.LoginRequest, error) { - var req model.LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - - return &req, nil -} - -func parseUserRoleRequest(r *http.Request) (*model.UserRole, error) { - var req model.UserRole - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - - return &req, nil -} - -func parseEditOrgRequest(r *http.Request) (*types.Organization, error) { - var req types.Organization - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - - return &req, nil -} - -func parseResetPasswordRequest(r *http.Request) (*model.ResetPasswordRequest, error) { - var req model.ResetPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - if err := auth.ValidatePassword(req.Password); err != nil { - return nil, err - } - - return &req, nil -} - -func parseChangePasswordRequest(r *http.Request) (*model.ChangePasswordRequest, error) { - id := mux.Vars(r)["id"] - var req model.ChangePasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return nil, err - } - req.UserId = id - if err := auth.ValidatePassword(req.NewPassword); err != nil { - return nil, err - } - - return &req, nil -} - func parseAggregateAttributeRequest(r *http.Request) (*v3.AggregateAttributeRequest, error) { var req v3.AggregateAttributeRequest diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index bf022fdc2614..f5a0bf77d44e 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -149,6 +149,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) { } telemetry.GetInstance().SetReader(reader) + telemetry.GetInstance().SetSqlStore(serverOptions.SigNoz.SQLStore) quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(serverOptions.SigNoz.SQLStore)) quickFilter := quickfilter.NewAPI(quickfiltermodule) apiHandler, err := NewAPIHandler(APIHandlerOpts{ diff --git a/pkg/query-service/auth/auth.go b/pkg/query-service/auth/auth.go index a09f982bb7c2..e29d02bee6fc 100644 --- a/pkg/query-service/auth/auth.go +++ b/pkg/query-service/auth/auth.go @@ -1,405 +1,19 @@ package auth import ( - "bytes" "context" - "fmt" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - "os" - "text/template" - "time" - - "github.com/google/uuid" - "github.com/pkg/errors" "github.com/SigNoz/signoz/pkg/alertmanager" "github.com/SigNoz/signoz/pkg/modules/organization" - "github.com/SigNoz/signoz/pkg/query-service/constants" - "github.com/SigNoz/signoz/pkg/query-service/dao" - "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/query-service/telemetry" - "github.com/SigNoz/signoz/pkg/query-service/utils" - smtpservice "github.com/SigNoz/signoz/pkg/query-service/utils/smtpService" - "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/valuer" - "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" + + "github.com/SigNoz/signoz/pkg/query-service/model" + "github.com/SigNoz/signoz/pkg/types" ) -const ( - opaqueTokenSize = 16 - minimumPasswordLength = 8 -) - -var ( - ErrorInvalidCreds = fmt.Errorf("invalid credentials") - ErrorEmptyRequest = errors.New("Empty request") - ErrorInvalidRole = errors.New("Invalid role") - ErrorInvalidInviteToken = errors.New("Invalid invite token") - ErrorAskAdmin = errors.New("An invitation is needed to create an account. Please ask your admin (the person who has first installed SIgNoz) to send an invite.") -) - -type InviteEmailData struct { - CustomerName string - InviterName string - InviterEmail string - Link string -} - -// The root user should be able to invite people to create account on SigNoz cluster. -func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteResponse, error) { - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - return nil, err - } - - token, err := utils.RandomHex(opaqueTokenSize) - if err != nil { - return nil, errors.Wrap(err, "failed to generate invite token") - } - - user, apiErr := dao.DB().GetUserByEmail(ctx, req.Email) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "Failed to check already existing user") - } - - if user != nil { - return nil, errors.New("User already exists with the same email") - } - // Check if an invite already exists - invite, apiErr := dao.DB().GetInviteFromEmail(ctx, req.Email) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "Failed to check existing invite") - } - - if invite != nil { - return nil, errors.New("An invite already exists for this email") - } - - role, err := authtypes.NewRole(req.Role) - if err != nil { - return nil, err - } - - au, apiErr := dao.DB().GetUser(ctx, claims.UserID) - if apiErr != nil { - return nil, errors.Wrap(err, "failed to query admin user from the DB") - } - - inv := &types.Invite{ - Identifiable: types.Identifiable{ - ID: valuer.GenerateUUID(), - }, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: req.Name, - Email: req.Email, - Token: token, - Role: role.String(), - OrgID: au.OrgID, - } - - if err := dao.DB().CreateInviteEntry(ctx, inv); err != nil { - return nil, errors.Wrap(err.Err, "failed to write to DB") - } - - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_SENT, map[string]interface{}{ - "invited user email": req.Email, - }, au.Email, true, false) - - // send email if SMTP is enabled - if os.Getenv("SMTP_ENABLED") == "true" && req.FrontendBaseUrl != "" { - inviteEmail(req, au, token) - } - - return &model.InviteResponse{Email: inv.Email, InviteToken: inv.Token}, nil -} - -func InviteUsers(ctx context.Context, req *model.BulkInviteRequest) (*model.BulkInviteResponse, error) { - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - return nil, err - } - - response := &model.BulkInviteResponse{ - Status: "success", - Summary: model.InviteSummary{TotalInvites: len(req.Users)}, - SuccessfulInvites: []model.SuccessfulInvite{}, - FailedInvites: []model.FailedInvite{}, - } - - au, apiErr := dao.DB().GetUser(ctx, claims.UserID) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "failed to query admin user from the DB") - } - - for _, inviteReq := range req.Users { - inviteResp, err := inviteUser(ctx, &inviteReq, au) - if err != nil { - response.FailedInvites = append(response.FailedInvites, model.FailedInvite{ - Email: inviteReq.Email, - Error: err.Error(), - }) - response.Summary.FailedInvites++ - } else { - response.SuccessfulInvites = append(response.SuccessfulInvites, model.SuccessfulInvite{ - Email: inviteResp.Email, - InviteLink: fmt.Sprintf("%s/signup?token=%s", inviteReq.FrontendBaseUrl, inviteResp.InviteToken), - Status: "sent", - }) - response.Summary.SuccessfulInvites++ - } - } - - // Update the status based on the results - if response.Summary.FailedInvites == response.Summary.TotalInvites { - response.Status = "failure" - } else if response.Summary.FailedInvites > 0 { - response.Status = "partial_success" - } - - return response, nil -} - -// Helper function to handle individual invites -func inviteUser(ctx context.Context, req *model.InviteRequest, au *types.GettableUser) (*model.InviteResponse, error) { - token, err := utils.RandomHex(opaqueTokenSize) - if err != nil { - return nil, errors.Wrap(err, "failed to generate invite token") - } - - user, apiErr := dao.DB().GetUserByEmail(ctx, req.Email) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "Failed to check already existing user") - } - - if user != nil { - return nil, errors.New("User already exists with the same email") - } - - // Check if an invite already exists - invite, apiErr := dao.DB().GetInviteFromEmail(ctx, req.Email) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "Failed to check existing invite") - } - - if invite != nil { - return nil, errors.New("An invite already exists for this email") - } - - role, err := authtypes.NewRole(req.Role) - if err != nil { - return nil, err - } - - inv := &types.Invite{ - Identifiable: types.Identifiable{ - ID: valuer.GenerateUUID(), - }, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - Name: req.Name, - Email: req.Email, - Token: token, - Role: role.String(), - OrgID: au.OrgID, - } - - if err := dao.DB().CreateInviteEntry(ctx, inv); err != nil { - return nil, errors.Wrap(err.Err, "failed to write to DB") - } - - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_SENT, map[string]interface{}{ - "invited user email": req.Email, - }, au.Email, true, false) - - // send email if SMTP is enabled - if os.Getenv("SMTP_ENABLED") == "true" && req.FrontendBaseUrl != "" { - inviteEmail(req, au, token) - } - - return &model.InviteResponse{Email: inv.Email, InviteToken: inv.Token}, nil -} - -func inviteEmail(req *model.InviteRequest, au *types.GettableUser, token string) { - smtp := smtpservice.GetInstance() - data := InviteEmailData{ - CustomerName: req.Name, - InviterName: au.Name, - InviterEmail: au.Email, - Link: fmt.Sprintf("%s/signup?token=%s", req.FrontendBaseUrl, token), - } - - tmpl, err := template.ParseFiles(constants.InviteEmailTemplate) - if err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } - - var body bytes.Buffer - if err := tmpl.Execute(&body, data); err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } - - err = smtp.SendEmail( - req.Email, - au.Name+" has invited you to their team in SigNoz", - body.String(), - ) - if err != nil { - zap.L().Error("failed to send email", zap.Error(err)) - return - } -} - -// RevokeInvite is used to revoke the invitation for the given email. -func RevokeInvite(ctx context.Context, email string) error { - claims, err := authtypes.ClaimsFromContext(ctx) - if err != nil { - return err - } - - if err := dao.DB().DeleteInvitation(ctx, claims.OrgID, email); err != nil { - return errors.Wrap(err.Err, "failed to write to DB") - } - return nil -} - -// GetInvite returns an invitation object for the given token. -func GetInvite(ctx context.Context, token string, organizationModule organization.Module) (*model.InvitationResponseObject, error) { - zap.L().Debug("GetInvite method invoked for token", zap.String("token", token)) - - inv, apiErr := dao.DB().GetInviteFromToken(ctx, token) - if apiErr != nil { - return nil, errors.Wrap(apiErr.Err, "failed to query the DB") - } - - if inv == nil { - return nil, errors.New("user is not invited") - } - - orgID, err := valuer.NewUUID(inv.OrgID) - if err != nil { - return nil, err - } - org, err := organizationModule.Get(ctx, orgID) - if err != nil { - return nil, errors.Wrap(err, "failed to query the DB") - } - return &model.InvitationResponseObject{ - Name: inv.Name, - Email: inv.Email, - Token: inv.Token, - CreatedAt: inv.CreatedAt.Unix(), - Role: inv.Role, - Organization: org.DisplayName, - }, nil -} - -func ValidateInvite(ctx context.Context, req *RegisterRequest) (*types.Invite, error) { - invitation, err := dao.DB().GetInviteFromEmail(ctx, req.Email) - if err != nil { - return nil, errors.Wrap(err.Err, "Failed to read from DB") - } - - if invitation == nil { - return nil, ErrorAskAdmin - } - - if invitation.Token != req.InviteToken { - return nil, ErrorInvalidInviteToken - } - - return invitation, nil -} - -func CreateResetPasswordToken(ctx context.Context, userId string) (*types.ResetPasswordRequest, error) { - token, err := utils.RandomHex(opaqueTokenSize) - if err != nil { - return nil, errors.Wrap(err, "failed to generate reset password token") - } - - req := &types.ResetPasswordRequest{ - Identifiable: types.Identifiable{ - ID: valuer.GenerateUUID(), - }, - UserID: userId, - Token: token, - } - if apiErr := dao.DB().CreateResetPasswordEntry(ctx, req); err != nil { - return nil, errors.Wrap(apiErr.Err, "failed to write to DB") - } - return req, nil -} - -func ResetPassword(ctx context.Context, req *model.ResetPasswordRequest) error { - entry, apiErr := dao.DB().GetResetPasswordEntry(ctx, req.Token) - if apiErr != nil { - return errors.Wrap(apiErr.Err, "failed to query the DB") - } - - if entry == nil { - return errors.New("Invalid reset password request") - } - - hash, err := PasswordHash(req.Password) - if err != nil { - return errors.Wrap(err, "Failed to generate password hash") - } - - if apiErr := dao.DB().UpdateUserPassword(ctx, hash, entry.UserID); apiErr != nil { - return apiErr.Err - } - - if apiErr := dao.DB().DeleteResetPasswordEntry(ctx, req.Token); apiErr != nil { - return errors.Wrap(apiErr.Err, "failed to delete reset token from DB") - } - - return nil -} - -func ChangePassword(ctx context.Context, req *model.ChangePasswordRequest) *model.ApiError { - user, apiErr := dao.DB().GetUser(ctx, req.UserId) - if apiErr != nil { - return apiErr - } - - if user == nil || !passwordMatch(user.Password, req.OldPassword) { - return model.ForbiddenError(ErrorInvalidCreds) - } - - hash, err := PasswordHash(req.NewPassword) - if err != nil { - return model.InternalError(errors.New("Failed to generate password hash")) - } - - if apiErr := dao.DB().UpdateUserPassword(ctx, hash, user.ID); apiErr != nil { - return apiErr - } - - return nil -} - -type RegisterRequest struct { - Name string `json:"name"` - OrgID string `json:"orgId"` - OrgDisplayName string `json:"orgDisplayName"` - OrgName string `json:"orgName"` - Email string `json:"email"` - Password string `json:"password"` - InviteToken string `json:"token"` - - // reference URL to track where the register request is coming from - SourceUrl string `json:"sourceUrl"` -} - -func RegisterFirstUser(ctx context.Context, req *RegisterRequest, organizationModule organization.Module) (*types.User, *model.ApiError) { +func RegisterOrgAndFirstUser(ctx context.Context, req *types.PostableRegisterOrgAndAdmin, organizationModule organization.Module, userModule user.Module) (*types.User, *model.ApiError) { if req.Email == "" { return nil, model.BadRequest(model.ErrEmailRequired{}) } @@ -414,245 +28,38 @@ func RegisterFirstUser(ctx context.Context, req *RegisterRequest, organizationMo return nil, model.InternalError(err) } - var hash string - hash, err = PasswordHash(req.Password) + user, err := types.NewUser(req.Name, req.Email, types.RoleAdmin.String(), organization.ID.StringValue()) if err != nil { - zap.L().Error("failed to generate password hash when registering a user", zap.Error(err)) - return nil, model.InternalError(model.ErrSignupFailed{}) + return nil, model.InternalError(err) } - user := &types.User{ - ID: uuid.New().String(), - Name: req.Name, - Email: req.Email, - Password: hash, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - }, - ProfilePictureURL: "", // Currently unused - Role: authtypes.RoleAdmin.String(), - OrgID: organization.ID.StringValue(), - } - - return dao.DB().CreateUser(ctx, user, true) -} - -// RegisterInvitedUser handles registering a invited user -func RegisterInvitedUser(ctx context.Context, req *RegisterRequest, nopassword bool) (*types.User, *model.ApiError) { - - if req.InviteToken == "" { - return nil, model.BadRequest(ErrorAskAdmin) - } - - if !nopassword && req.Password == "" { - return nil, model.BadRequest(model.ErrPasswordRequired{}) - } - - invite, err := ValidateInvite(ctx, req) + password, err := types.NewFactorPassword(req.Password) if err != nil { - zap.L().Error("failed to validate invite token", zap.Error(err)) - return nil, model.BadRequest(model.ErrSignupFailed{}) + return nil, model.InternalError(err) } - // checking if user email already exists, this is defensive but - // required as delete invitation and user creation dont happen - // in the same transaction at the end of this function - userPayload, apierr := dao.DB().GetUserByEmail(ctx, invite.Email) - if apierr != nil { - zap.L().Error("failed to get user by email", zap.Error(apierr.Err)) - return nil, apierr + user, err = userModule.CreateUserWithPassword(ctx, user, password) + if err != nil { + return nil, model.InternalError(err) } - if userPayload != nil { - // user already exists - return &userPayload.User, nil - } - - if invite.OrgID == "" { - zap.L().Error("failed to find org in the invite") - return nil, model.InternalError(fmt.Errorf("invalid invite, org not found")) - } - - if invite.Role == "" { - // if role is not provided, default to viewer - invite.Role = authtypes.RoleViewer.String() - } - - var hash string - - // check if password is not empty, as for SSO case it can be - if req.Password != "" { - hash, err = PasswordHash(req.Password) - if err != nil { - zap.L().Error("failed to generate password hash when registering a user", zap.Error(err)) - return nil, model.InternalError(model.ErrSignupFailed{}) - } - } else { - hash, err = PasswordHash(utils.GeneratePassowrd()) - if err != nil { - zap.L().Error("failed to generate password hash when registering a user", zap.Error(err)) - return nil, model.InternalError(model.ErrSignupFailed{}) - } - } - - user := &types.User{ - ID: uuid.New().String(), - Name: req.Name, - Email: req.Email, - Password: hash, - TimeAuditable: types.TimeAuditable{ - CreatedAt: time.Now(), - }, - ProfilePictureURL: "", // Currently unused - Role: invite.Role, - OrgID: invite.OrgID, - } - - // TODO(Ahsan): Ideally create user and delete invitation should happen in a txn. - user, apiErr := dao.DB().CreateUser(ctx, user, false) - if apiErr != nil { - zap.L().Error("CreateUser failed", zap.Error(apiErr.Err)) - return nil, apiErr - } - - apiErr = dao.DB().DeleteInvitation(ctx, user.OrgID, user.Email) - if apiErr != nil { - zap.L().Error("delete invitation failed", zap.Error(apiErr.Err)) - return nil, apiErr - } - - telemetry.GetInstance().IdentifyUser(user) - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_ACCEPTED, nil, req.Email, true, false) - return user, nil } -// Register registers a new user. For the first register request, it doesn't need an invite token -// and also the first registration is an enforced ADMIN registration. Every subsequent request will -// need an invite token to go through. -func Register(ctx context.Context, req *RegisterRequest, alertmanager alertmanager.Alertmanager, organizationModule organization.Module, quickfiltermodule quickfilter.Usecase) (*types.User, *model.ApiError) { - users, err := dao.DB().GetUsers(ctx) +// First user registration +func Register(ctx context.Context, req *types.PostableRegisterOrgAndAdmin, alertmanager alertmanager.Alertmanager, organizationModule organization.Module, userModule user.Module, quickfiltermodule quickfilter.Usecase) (*types.User, *model.ApiError) { + user, err := RegisterOrgAndFirstUser(ctx, req, organizationModule, userModule) if err != nil { - return nil, model.InternalError(fmt.Errorf("failed to get user count")) - } - - switch len(users) { - case 0: - user, err := RegisterFirstUser(ctx, req, organizationModule) - if err != nil { - return nil, err - } - - if err := alertmanager.SetDefaultConfig(ctx, user.OrgID); err != nil { - return nil, model.InternalError(err) - } - if err := quickfiltermodule.SetDefaultConfig(ctx, valuer.MustNewUUID(user.OrgID)); err != nil { - return nil, model.InternalError(err) - } - return user, nil - default: - return RegisterInvitedUser(ctx, req, false) - } -} - -// Login method returns access and refresh tokens on successful login, else it errors out. -func Login(ctx context.Context, request *model.LoginRequest, jwt *authtypes.JWT) (*model.LoginResponse, error) { - user, err := authenticateLogin(ctx, request, jwt) - if err != nil { - zap.L().Error("Failed to authenticate login request", zap.Error(err)) return nil, err } - userjwt, err := GenerateJWTForUser(&user.User, jwt) - if err != nil { - zap.L().Error("Failed to generate JWT against login creds", zap.Error(err)) - return nil, err + if err := alertmanager.SetDefaultConfig(ctx, user.OrgID); err != nil { + return nil, model.InternalError(err) } - // ignoring identity for unnamed users as a patch for #3863 - if user.Name != "" { - telemetry.GetInstance().IdentifyUser(&user.User) + if err := quickfiltermodule.SetDefaultConfig(ctx, valuer.MustNewUUID(user.OrgID)); err != nil { + return nil, model.InternalError(err) } - return &model.LoginResponse{ - UserJwtObject: userjwt, - UserId: user.User.ID, - }, nil -} - -// authenticateLogin is responsible for querying the DB and validating the credentials. -func authenticateLogin(ctx context.Context, req *model.LoginRequest, jwt *authtypes.JWT) (*types.GettableUser, error) { - // If refresh token is valid, then simply authorize the login request. - if len(req.RefreshToken) > 0 { - // parse the refresh token - claims, err := jwt.Claims(req.RefreshToken) - if err != nil { - return nil, errors.Wrap(err, "failed to parse refresh token") - } - - user := &types.GettableUser{ - User: types.User{ - ID: claims.UserID, - Role: claims.Role.String(), - Email: claims.Email, - OrgID: claims.OrgID, - }, - } - return user, nil - } - - user, err := dao.DB().GetUserByEmail(ctx, req.Email) - if err != nil { - return nil, errors.Wrap(err.Err, "user not found") - } - if user == nil || !passwordMatch(user.Password, req.Password) { - return nil, ErrorInvalidCreds - } return user, nil } - -// Generate hash from the password. -func PasswordHash(pass string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) - if err != nil { - return "", err - } - return string(hash), nil -} - -// Checks if the given password results in the given hash. -func passwordMatch(hash, password string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil -} - -func GenerateJWTForUser(user *types.User, jwt *authtypes.JWT) (model.UserJwtObject, error) { - role, err := authtypes.NewRole(user.Role) - if err != nil { - return model.UserJwtObject{}, err - } - - accessJwt, accessClaims, err := jwt.AccessToken(user.OrgID, user.ID, user.Email, role) - if err != nil { - return model.UserJwtObject{}, err - } - - refreshJwt, refreshClaims, err := jwt.RefreshToken(user.OrgID, user.ID, user.Email, role) - if err != nil { - return model.UserJwtObject{}, err - } - - return model.UserJwtObject{ - AccessJwt: accessJwt, - RefreshJwt: refreshJwt, - AccessJwtExpiry: accessClaims.ExpiresAt.Unix(), - RefreshJwtExpiry: refreshClaims.ExpiresAt.Unix(), - }, nil -} - -func ValidatePassword(password string) error { - if len(password) < minimumPasswordLength { - return errors.Errorf("Password should be atleast %d characters.", minimumPasswordLength) - } - return nil -} diff --git a/pkg/query-service/dao/interface.go b/pkg/query-service/dao/interface.go index 70c5c9082339..7d92d8851c0b 100644 --- a/pkg/query-service/dao/interface.go +++ b/pkg/query-service/dao/interface.go @@ -5,7 +5,6 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" ) type ModelDao interface { @@ -14,37 +13,10 @@ type ModelDao interface { } type Queries interface { - GetInviteFromEmail(ctx context.Context, email string) (*types.Invite, *model.ApiError) - GetInviteFromToken(ctx context.Context, token string) (*types.Invite, *model.ApiError) - GetInvites(ctx context.Context, orgID string) ([]types.Invite, *model.ApiError) - - GetUser(ctx context.Context, id string) (*types.GettableUser, *model.ApiError) - GetUserByEmail(ctx context.Context, email string) (*types.GettableUser, *model.ApiError) - GetUsers(ctx context.Context) ([]types.GettableUser, *model.ApiError) - GetUsersWithOpts(ctx context.Context, limit int) ([]types.GettableUser, *model.ApiError) - - GetResetPasswordEntry(ctx context.Context, token string) (*types.ResetPasswordRequest, *model.ApiError) - GetUsersByOrg(ctx context.Context, orgId string) ([]types.GettableUser, *model.ApiError) - GetUsersByRole(ctx context.Context, role authtypes.Role) ([]types.GettableUser, *model.ApiError) - GetApdexSettings(ctx context.Context, orgID string, services []string) ([]types.ApdexSettings, *model.ApiError) - - PrecheckLogin(ctx context.Context, email, sourceUrl string) (*model.PrecheckResponse, model.BaseApiError) } type Mutations interface { - CreateInviteEntry(ctx context.Context, req *types.Invite) *model.ApiError - DeleteInvitation(ctx context.Context, orgID string, email string) *model.ApiError - - CreateUser(ctx context.Context, user *types.User, isFirstUser bool) (*types.User, *model.ApiError) - EditUser(ctx context.Context, update *types.User) (*types.User, *model.ApiError) - DeleteUser(ctx context.Context, id string) *model.ApiError - - CreateResetPasswordEntry(ctx context.Context, req *types.ResetPasswordRequest) *model.ApiError - DeleteResetPasswordEntry(ctx context.Context, token string) *model.ApiError - - UpdateUserPassword(ctx context.Context, hash, userId string) *model.ApiError - UpdateUserRole(ctx context.Context, userId string, role authtypes.Role) *model.ApiError - + UpdateUserRole(ctx context.Context, userId string, role types.Role) *model.ApiError SetApdexSettings(ctx context.Context, orgID string, set *types.ApdexSettings) *model.ApiError } diff --git a/pkg/query-service/dao/sqlite/connection.go b/pkg/query-service/dao/sqlite/connection.go index 1138b5d82dc0..ee4d46b8afa4 100644 --- a/pkg/query-service/dao/sqlite/connection.go +++ b/pkg/query-service/dao/sqlite/connection.go @@ -24,8 +24,8 @@ func InitDB(sqlStore sqlstore.SQLStore) (*ModelDaoSqlite, error) { return nil, err } - telemetry.GetInstance().SetUserCountCallback(mds.GetUserCount) - telemetry.GetInstance().SetGetUsersCallback(mds.GetUsers) + telemetry.GetInstance().SetGetUsersCallback(telemetry.GetUsers) + telemetry.GetInstance().SetUserCountCallback(telemetry.GetUserCount) return mds, nil } @@ -62,12 +62,5 @@ func (mds *ModelDaoSqlite) initializeOrgPreferences(ctx context.Context) error { // set telemetry fields from userPreferences telemetry.GetInstance().SetDistinctId(org.ID.StringValue()) - users, _ := mds.GetUsers(ctx) - countUsers := len(users) - if countUsers > 0 { - telemetry.GetInstance().SetCompanyDomain(users[countUsers-1].Email) - telemetry.GetInstance().SetUserEmail(users[countUsers-1].Email) - } - return nil } diff --git a/pkg/query-service/dao/sqlite/rbac.go b/pkg/query-service/dao/sqlite/rbac.go index d4f13f5aec4e..75b3c76e20b6 100644 --- a/pkg/query-service/dao/sqlite/rbac.go +++ b/pkg/query-service/dao/sqlite/rbac.go @@ -2,98 +2,11 @@ package sqlite import ( "context" - "fmt" "github.com/SigNoz/signoz/pkg/query-service/model" - "github.com/SigNoz/signoz/pkg/query-service/telemetry" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/authtypes" - "github.com/pkg/errors" ) -func (mds *ModelDaoSqlite) CreateInviteEntry(ctx context.Context, req *types.Invite) *model.ApiError { - _, err := mds.bundb.NewInsert(). - Model(req). - Exec(ctx) - - if err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return nil -} - -func (mds *ModelDaoSqlite) DeleteInvitation(ctx context.Context, orgID string, email string) *model.ApiError { - _, err := mds.bundb.NewDelete(). - Model(&types.Invite{}). - Where("org_id = ?", orgID). - Where("email = ?", email). - Exec(ctx) - if err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return nil -} - -// TODO: Make this work with org id -func (mds *ModelDaoSqlite) GetInviteFromEmail(ctx context.Context, email string, -) (*types.Invite, *model.ApiError) { - - invites := []types.Invite{} - err := mds.bundb.NewSelect(). - Model(&invites). - Where("email = ?", email). - Scan(ctx) - - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - if len(invites) > 1 { - return nil, &model.ApiError{ - Typ: model.ErrorInternal, - Err: errors.Errorf("Found multiple invites for the email: %s", email)} - } - - if len(invites) == 0 { - return nil, nil - } - return &invites[0], nil -} - -func (mds *ModelDaoSqlite) GetInviteFromToken(ctx context.Context, token string, -) (*types.Invite, *model.ApiError) { - // This won't take org id because it's a public facing API - - invites := []types.Invite{} - err := mds.bundb.NewSelect(). - Model(&invites). - Where("token = ?", token). - Scan(ctx) - - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - if len(invites) > 1 { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - - if len(invites) == 0 { - return nil, nil - } - return &invites[0], nil -} - -func (mds *ModelDaoSqlite) GetInvites(ctx context.Context, orgID string) ([]types.Invite, *model.ApiError) { - invites := []types.Invite{} - err := mds.bundb.NewSelect(). - Model(&invites). - Where("org_id = ?", orgID). - Scan(ctx) - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return invites, nil -} - func (mds *ModelDaoSqlite) GetOrgs(ctx context.Context) ([]types.Organization, *model.ApiError) { var orgs []types.Organization err := mds.bundb.NewSelect(). @@ -106,64 +19,7 @@ func (mds *ModelDaoSqlite) GetOrgs(ctx context.Context) ([]types.Organization, * return orgs, nil } -func (mds *ModelDaoSqlite) CreateUser(ctx context.Context, - user *types.User, isFirstUser bool) (*types.User, *model.ApiError) { - _, err := mds.bundb.NewInsert(). - Model(user). - Exec(ctx) - - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - - data := map[string]interface{}{ - "name": user.Name, - "email": user.Email, - "firstRegistration": false, - } - - if isFirstUser { - data["firstRegistration"] = true - } - - telemetry.GetInstance().IdentifyUser(user) - telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER, data, user.Email, true, false) - - return user, nil -} - -func (mds *ModelDaoSqlite) EditUser(ctx context.Context, - update *types.User) (*types.User, *model.ApiError) { - _, err := mds.bundb.NewUpdate(). - Model(update). - Column("name"). - Column("org_id"). - Column("email"). - Where("id = ?", update.ID). - Exec(ctx) - - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return update, nil -} - -func (mds *ModelDaoSqlite) UpdateUserPassword(ctx context.Context, passwordHash, - userId string) *model.ApiError { - - _, err := mds.bundb.NewUpdate(). - Model(&types.User{}). - Set("password = ?", passwordHash). - Where("id = ?", userId). - Exec(ctx) - - if err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return nil -} - -func (mds *ModelDaoSqlite) UpdateUserRole(ctx context.Context, userId string, role authtypes.Role) *model.ApiError { +func (mds *ModelDaoSqlite) UpdateUserRole(ctx context.Context, userId string, role types.Role) *model.ApiError { _, err := mds.bundb.NewUpdate(). Model(&types.User{}). @@ -176,223 +32,3 @@ func (mds *ModelDaoSqlite) UpdateUserRole(ctx context.Context, userId string, ro } return nil } - -func (mds *ModelDaoSqlite) DeleteUser(ctx context.Context, id string) *model.ApiError { - result, err := mds.bundb.NewDelete(). - Model(&types.User{}). - Where("id = ?", id). - Exec(ctx) - - if err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - - affectedRows, err := result.RowsAffected() - if err != nil { - return &model.ApiError{Typ: model.ErrorExec, Err: err} - } - if affectedRows == 0 { - return &model.ApiError{ - Typ: model.ErrorNotFound, - Err: fmt.Errorf("no user found with id: %s", id), - } - } - - return nil -} - -func (mds *ModelDaoSqlite) GetUser(ctx context.Context, - id string) (*types.GettableUser, *model.ApiError) { - - users := []types.GettableUser{} - query := mds.bundb.NewSelect(). - Table("users"). - Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role"). - ColumnExpr("o.display_name as organization"). - Join("JOIN organizations o ON o.id = users.org_id"). - Where("users.id = ?", id) - - if err := query.Scan(ctx, &users); err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - if len(users) > 1 { - return nil, &model.ApiError{ - Typ: model.ErrorInternal, - Err: errors.New("Found multiple users with same ID"), - } - } - - if len(users) == 0 { - return nil, nil - } - - return &users[0], nil -} - -func (mds *ModelDaoSqlite) GetUserByEmail(ctx context.Context, - email string) (*types.GettableUser, *model.ApiError) { - - if email == "" { - return nil, &model.ApiError{ - Typ: model.ErrorBadData, - Err: fmt.Errorf("empty email address"), - } - } - - users := []types.GettableUser{} - query := mds.bundb.NewSelect(). - Table("users"). - Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role"). - ColumnExpr("o.display_name as organization"). - Join("JOIN organizations o ON o.id = users.org_id"). - Where("users.email = ?", email) - - if err := query.Scan(ctx, &users); err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - - if len(users) > 1 { - return nil, &model.ApiError{ - Typ: model.ErrorInternal, - Err: errors.New("Found multiple users with same ID."), - } - } - - if len(users) == 0 { - return nil, nil - } - return &users[0], nil -} - -// GetUsers fetches total user count -func (mds *ModelDaoSqlite) GetUsers(ctx context.Context) ([]types.GettableUser, *model.ApiError) { - return mds.GetUsersWithOpts(ctx, 0) -} - -// GetUsersWithOpts fetches users and supports additional search options -func (mds *ModelDaoSqlite) GetUsersWithOpts(ctx context.Context, limit int) ([]types.GettableUser, *model.ApiError) { - users := []types.GettableUser{} - - query := mds.bundb.NewSelect(). - Table("users"). - Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role"). - ColumnExpr("users.role as role"). - ColumnExpr("o.display_name as organization"). - Join("JOIN organizations o ON o.id = users.org_id") - - if limit > 0 { - query.Limit(limit) - } - err := query.Scan(ctx, &users) - - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return users, nil -} - -func (mds *ModelDaoSqlite) GetUsersByOrg(ctx context.Context, - orgId string) ([]types.GettableUser, *model.ApiError) { - - users := []types.GettableUser{} - - query := mds.bundb.NewSelect(). - Table("users"). - Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role"). - ColumnExpr("users.role as role"). - ColumnExpr("o.display_name as organization"). - Join("JOIN organizations o ON o.id = users.org_id"). - Where("users.org_id = ?", orgId) - - err := query.Scan(ctx, &users) - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return users, nil -} - -func (mds *ModelDaoSqlite) GetUsersByRole(ctx context.Context, role authtypes.Role) ([]types.GettableUser, *model.ApiError) { - users := []types.GettableUser{} - - query := mds.bundb.NewSelect(). - Table("users"). - Column("users.id", "users.name", "users.email", "users.password", "users.created_at", "users.profile_picture_url", "users.org_id", "users.role"). - ColumnExpr("users.role as role"). - ColumnExpr("o.display_name as organization"). - Join("JOIN organizations o ON o.id = users.org_id"). - Where("users.role = ?", role) - - err := query.Scan(ctx, &users) - if err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return users, nil -} - -func (mds *ModelDaoSqlite) CreateResetPasswordEntry(ctx context.Context, req *types.ResetPasswordRequest) *model.ApiError { - - if _, err := mds.bundb.NewInsert(). - Model(req). - Exec(ctx); err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return nil -} - -func (mds *ModelDaoSqlite) DeleteResetPasswordEntry(ctx context.Context, token string) *model.ApiError { - _, err := mds.bundb.NewDelete(). - Model(&types.ResetPasswordRequest{}). - Where("token = ?", token). - Exec(ctx) - - if err != nil { - return &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - return nil -} - -func (mds *ModelDaoSqlite) GetResetPasswordEntry(ctx context.Context, token string) (*types.ResetPasswordRequest, *model.ApiError) { - - entries := []types.ResetPasswordRequest{} - - if err := mds.bundb.NewSelect(). - Model(&entries). - Where("token = ?", token). - Scan(ctx); err != nil { - return nil, &model.ApiError{Typ: model.ErrorInternal, Err: err} - } - if len(entries) > 1 { - return nil, &model.ApiError{Typ: model.ErrorInternal, - Err: errors.New("Multiple entries for reset token is found")} - } - - if len(entries) == 0 { - return nil, nil - } - return &entries[0], nil -} - -func (mds *ModelDaoSqlite) PrecheckLogin(ctx context.Context, email, sourceUrl string) (*model.PrecheckResponse, model.BaseApiError) { - // assume user is valid unless proven otherwise and assign default values for rest of the fields - resp := &model.PrecheckResponse{IsUser: true, CanSelfRegister: false, SSO: false, SsoUrl: "", SsoError: ""} - - // check if email is a valid user - userPayload, baseApiErr := mds.GetUserByEmail(ctx, email) - if baseApiErr != nil { - return resp, baseApiErr - } - - if userPayload == nil { - resp.IsUser = false - } - - return resp, nil -} - -func (mds *ModelDaoSqlite) GetUserCount(ctx context.Context) (int, error) { - users, err := mds.GetUsers(ctx) - if err != nil { - return 0, err - } - return len(users), nil -} diff --git a/pkg/query-service/main.go b/pkg/query-service/main.go index 23faff9d78e7..1c4d891f83f5 100644 --- a/pkg/query-service/main.go +++ b/pkg/query-service/main.go @@ -9,9 +9,12 @@ import ( "github.com/SigNoz/signoz/pkg/config" "github.com/SigNoz/signoz/pkg/config/envprovider" "github.com/SigNoz/signoz/pkg/config/fileprovider" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/signoz" + "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types/authtypes" "github.com/SigNoz/signoz/pkg/version" "github.com/SigNoz/signoz/pkg/zeus" @@ -108,6 +111,12 @@ func main() { signoz.NewWebProviderFactories(), signoz.NewSQLStoreProviderFactories(), signoz.NewTelemetryStoreProviderFactories(), + func(sqlstore sqlstore.SQLStore) user.Module { + return impluser.NewModule(impluser.NewStore(sqlstore)) + }, + func(userModule user.Module) user.Handler { + return impluser.NewHandler(userModule) + }, ) if err != nil { zap.L().Fatal("Failed to create signoz", zap.Error(err)) diff --git a/pkg/query-service/model/auth.go b/pkg/query-service/model/auth.go deleted file mode 100644 index 051ac428ca28..000000000000 --- a/pkg/query-service/model/auth.go +++ /dev/null @@ -1,99 +0,0 @@ -package model - -import "github.com/pkg/errors" - -var ( - ErrorTokenExpired = errors.New("Token is expired") -) - -type InviteRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Role string `json:"role"` - FrontendBaseUrl string `json:"frontendBaseUrl"` -} - -type InviteResponse struct { - Email string `json:"email"` - InviteToken string `json:"inviteToken"` -} - -type InvitationResponseObject struct { - Email string `json:"email" db:"email"` - Name string `json:"name" db:"name"` - Token string `json:"token" db:"token"` - CreatedAt int64 `json:"createdAt" db:"created_at"` - Role string `json:"role" db:"role"` - Organization string `json:"organization" db:"organization"` -} - -type BulkInviteRequest struct { - Users []InviteRequest `json:"users"` -} - -type BulkInviteResponse struct { - Status string `json:"status"` - Summary InviteSummary `json:"summary"` - SuccessfulInvites []SuccessfulInvite `json:"successful_invites"` - FailedInvites []FailedInvite `json:"failed_invites"` -} - -type InviteSummary struct { - TotalInvites int `json:"total_invites"` - SuccessfulInvites int `json:"successful_invites"` - FailedInvites int `json:"failed_invites"` -} - -type SuccessfulInvite struct { - Email string `json:"email"` - InviteLink string `json:"invite_link"` - Status string `json:"status"` -} - -type FailedInvite struct { - Email string `json:"email"` - Error string `json:"error"` -} - -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` - RefreshToken string `json:"refreshToken"` -} - -// PrecheckResponse contains login precheck response -type PrecheckResponse struct { - SSO bool `json:"sso"` - SsoUrl string `json:"ssoUrl"` - CanSelfRegister bool `json:"canSelfRegister"` - IsUser bool `json:"isUser"` - SsoError string `json:"ssoError"` -} - -type UserJwtObject struct { - AccessJwt string `json:"accessJwt"` - AccessJwtExpiry int64 `json:"accessJwtExpiry"` - RefreshJwt string `json:"refreshJwt"` - RefreshJwtExpiry int64 `json:"refreshJwtExpiry"` -} - -type LoginResponse struct { - UserJwtObject - UserId string `json:"userId"` -} - -type ChangePasswordRequest struct { - UserId string `json:"userId"` - OldPassword string `json:"oldPassword"` - NewPassword string `json:"newPassword"` -} - -type ResetPasswordRequest struct { - Password string `json:"password"` - Token string `json:"token"` -} - -type UserRole struct { - UserId string `json:"user_id"` - GroupName string `json:"group_name"` -} diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index c393bb8aa94f..59b33a92669f 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -19,6 +19,7 @@ import ( "github.com/SigNoz/signoz/pkg/query-service/interfaces" "github.com/SigNoz/signoz/pkg/query-service/model" v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" + "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/version" ) @@ -196,6 +197,7 @@ type Telemetry struct { isAnonymous bool distinctId string reader interfaces.Reader + sqlStore sqlstore.SQLStore companyDomain string minRandInt int maxRandInt int @@ -205,8 +207,8 @@ type Telemetry struct { mutex sync.RWMutex alertsInfoCallback func(ctx context.Context) (*model.AlertsInfo, error) - userCountCallback func(ctx context.Context) (int, error) - getUsersCallback func(ctx context.Context) ([]types.GettableUser, *model.ApiError) + userCountCallback func(ctx context.Context, store sqlstore.SQLStore) (int, error) + getUsersCallback func(ctx context.Context, store sqlstore.SQLStore) ([]TelemetryUser, error) dashboardsInfoCallback func(ctx context.Context) (*model.DashboardsInfo, error) savedViewsInfoCallback func(ctx context.Context) (*model.SavedViewsInfo, error) } @@ -215,11 +217,11 @@ func (a *Telemetry) SetAlertsInfoCallback(callback func(ctx context.Context) (*m a.alertsInfoCallback = callback } -func (a *Telemetry) SetUserCountCallback(callback func(ctx context.Context) (int, error)) { +func (a *Telemetry) SetUserCountCallback(callback func(ctx context.Context, store sqlstore.SQLStore) (int, error)) { a.userCountCallback = callback } -func (a *Telemetry) SetGetUsersCallback(callback func(ctx context.Context) ([]types.GettableUser, *model.ApiError)) { +func (a *Telemetry) SetGetUsersCallback(callback func(ctx context.Context, store sqlstore.SQLStore) ([]TelemetryUser, error)) { a.getUsersCallback = callback } @@ -317,7 +319,7 @@ func createTelemetry() { metricsTTL, _ := telemetry.reader.GetTTL(ctx, "", &model.GetTTLParams{Type: constants.MetricsTTL}) logsTTL, _ := telemetry.reader.GetTTL(ctx, "", &model.GetTTLParams{Type: constants.LogsTTL}) - userCount, _ := telemetry.userCountCallback(ctx) + userCount, _ := telemetry.userCountCallback(ctx, telemetry.sqlStore) data := map[string]interface{}{ "totalSpans": totalSpans, @@ -340,7 +342,7 @@ func createTelemetry() { data[key] = value } - users, apiErr := telemetry.getUsersCallback(ctx) + users, apiErr := telemetry.getUsersCallback(ctx, telemetry.sqlStore) if apiErr == nil { for _, user := range users { if user.Email == DEFAULT_CLOUD_EMAIL { @@ -351,6 +353,9 @@ func createTelemetry() { } alertsInfo, err := telemetry.alertsInfoCallback(ctx) + if err != nil { + telemetry.SendEvent(TELEMETRY_EVENT_DASHBOARDS_ALERTS, map[string]interface{}{"error": err.Error()}, "", true, false) + } if err == nil { dashboardsInfo, err := telemetry.dashboardsInfoCallback(ctx) if err == nil { @@ -442,9 +447,6 @@ func createTelemetry() { } } } - if err != nil || apiErr != nil { - telemetry.SendEvent(TELEMETRY_EVENT_DASHBOARDS_ALERTS, map[string]interface{}{"error": err.Error()}, "", true, false) - } if totalLogs > 0 { telemetry.SendIdentifyEvent(map[string]interface{}{"sent_logs": true}, "") @@ -554,7 +556,7 @@ func (a *Telemetry) IdentifyUser(user *types.User) { if a.saasOperator != nil { _ = a.saasOperator.Enqueue(analytics.Identify{ UserId: a.userEmail, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("role", user.Role), + Traits: analytics.NewTraits().SetName(user.DisplayName).SetEmail(user.Email).Set("role", user.Role), }) _ = a.saasOperator.Enqueue(analytics.Group{ @@ -567,7 +569,7 @@ func (a *Telemetry) IdentifyUser(user *types.User) { if a.ossOperator != nil { _ = a.ossOperator.Enqueue(analytics.Identify{ UserId: a.ipAddress, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("ip", a.ipAddress), + Traits: analytics.NewTraits().SetName(user.DisplayName).SetEmail(user.Email).Set("ip", a.ipAddress), }) // Updating a groups properties _ = a.ossOperator.Enqueue(analytics.Group{ @@ -799,6 +801,10 @@ func (a *Telemetry) SetReader(reader interfaces.Reader) { a.reader = reader } +func (a *Telemetry) SetSqlStore(store sqlstore.SQLStore) { + a.sqlStore = store +} + func GetInstance() *Telemetry { once.Do(func() { diff --git a/pkg/query-service/telemetry/user.go b/pkg/query-service/telemetry/user.go new file mode 100644 index 000000000000..b7ac3cc7bba6 --- /dev/null +++ b/pkg/query-service/telemetry/user.go @@ -0,0 +1,46 @@ +package telemetry + +import ( + "context" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" +) + +type TelemetryUser struct { + types.User + Organization string `json:"organization"` +} + +func GetUsers(ctx context.Context, sqlstore sqlstore.SQLStore) ([]TelemetryUser, error) { + return GetUsersWithOpts(ctx, 0, sqlstore) +} + +func GetUserCount(ctx context.Context, sqlstore sqlstore.SQLStore) (int, error) { + users, err := GetUsersWithOpts(ctx, 0, sqlstore) + if err != nil { + return 0, err + } + return len(users), nil +} + +// GetUsersWithOpts fetches users and supports additional search options +func GetUsersWithOpts(ctx context.Context, limit int, sqlstore sqlstore.SQLStore) ([]TelemetryUser, error) { + users := []TelemetryUser{} + + query := sqlstore.BunDB().NewSelect(). + Table("user"). + Column("user.id", "user.display_name", "user.email", "user.created_at", "user.org_id"). + ColumnExpr("o.display_name as organization"). + Join("JOIN organizations o ON o.id = user.org_id") + + if limit > 0 { + query.Limit(limit) + } + err := query.Scan(ctx, &users) + if err != nil { + return nil, errors.WrapNotFoundf(err, errors.CodeNotFound, "failed to get users") + } + return users, nil +} diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index bb7987d36010..4b36c5a30d57 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -4,16 +4,19 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "slices" "strings" "testing" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/constants" "github.com/SigNoz/signoz/pkg/query-service/dao" @@ -267,6 +270,7 @@ type FilterSuggestionsTestBed struct { testUser *types.User qsHttpHandler http.Handler mockClickhouse mockhouse.ClickConnMockCommon + userModule user.Module } func (tb *FilterSuggestionsTestBed) GetQBFilterSuggestionsForLogs( @@ -300,7 +304,9 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) - modules := signoz.NewModules(testDB) + userModule := impluser.NewModule(impluser.NewStore(testDB)) + userHandler := impluser.NewHandler(userModule) + modules := signoz.NewModules(testDB, userModule) quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ @@ -310,7 +316,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { JWT: jwt, Signoz: &signoz.SigNoz{ Modules: modules, - Handlers: signoz.NewHandlers(modules), + Handlers: signoz.NewHandlers(modules, userHandler), }, QuickFilters: quickFilterModule, }) @@ -326,7 +332,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { apiHandler.RegisterQueryRangeV3Routes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule) + user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -343,6 +349,7 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, + userModule: userModule, } } @@ -359,7 +366,7 @@ func (tb *FilterSuggestionsTestBed) QSGetRequest( } req, err := AuthenticatedRequestForTest( - tb.testUser, path, nil, + tb.userModule, tb.testUser, path, nil, ) if err != nil { tb.t.Fatalf("couldn't create authenticated test request: %v", err) diff --git a/pkg/query-service/tests/integration/logparsingpipeline_test.go b/pkg/query-service/tests/integration/logparsingpipeline_test.go index bf6e577d770a..d4a0bb5f4055 100644 --- a/pkg/query-service/tests/integration/logparsingpipeline_test.go +++ b/pkg/query-service/tests/integration/logparsingpipeline_test.go @@ -10,6 +10,10 @@ import ( "testing" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/agentConf" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" @@ -21,6 +25,7 @@ import ( v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3" "github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr" "github.com/SigNoz/signoz/pkg/query-service/utils" + "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/pipelinetypes" @@ -448,6 +453,7 @@ type LogPipelinesTestBed struct { agentConfMgr *agentConf.Manager opampServer *opamp.Server opampClientConn *opamp.MockOpAmpConnection + userModule user.Module } // testDB can be injected for sharing a DB across multiple integration testbeds. @@ -471,17 +477,28 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli t.Fatalf("could not create a logparsingpipelines controller: %v", err) } + userModule := impluser.NewModule(impluser.NewStore(sqlStore)) + userHandler := impluser.NewHandler(userModule) + modules := signoz.NewModules(sqlStore, userModule) + handlers := signoz.NewHandlers(modules, userHandler) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(sqlStore))) + apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ AppDao: dao.DB(), LogsParsingPipelineController: controller, JWT: jwt, + Signoz: &signoz.SigNoz{ + Modules: modules, + Handlers: handlers, + }, + QuickFilters: quickFilterModule, }) if err != nil { t.Fatalf("could not create a new ApiHandler: %v", err) } organizationModule := implorganization.NewModule(implorganization.NewStore(sqlStore)) - user, apiErr := createTestUser(organizationModule) + user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -502,6 +519,7 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli testUser: user, apiHandler: apiHandler, agentConfMgr: agentConfMgr, + userModule: userModule, } } @@ -539,7 +557,7 @@ func (tb *LogPipelinesTestBed) PostPipelinesToQSExpectingStatusCode( expectedStatusCode int, ) *logparsingpipeline.PipelinesResponse { req, err := AuthenticatedRequestForTest( - tb.testUser, "/api/v1/logs/pipelines", postablePipelines, + tb.userModule, tb.testUser, "/api/v1/logs/pipelines", postablePipelines, ) if err != nil { tb.t.Fatalf("couldn't create authenticated test request: %v", err) @@ -594,7 +612,7 @@ func (tb *LogPipelinesTestBed) PostPipelinesToQS( func (tb *LogPipelinesTestBed) GetPipelinesFromQS() *logparsingpipeline.PipelinesResponse { req, err := AuthenticatedRequestForTest( - tb.testUser, "/api/v1/logs/pipelines/latest", nil, + tb.userModule, tb.testUser, "/api/v1/logs/pipelines/latest", nil, ) if err != nil { tb.t.Fatalf("couldn't create authenticated test request: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go index 4a591c5a112f..cb249f93708b 100644 --- a/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_cloud_integrations_test.go @@ -3,15 +3,18 @@ package tests import ( "encoding/json" "fmt" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "strings" "testing" "time" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" @@ -346,6 +349,7 @@ type CloudIntegrationsTestBed struct { testUser *types.User qsHttpHandler http.Handler mockClickhouse mockhouse.ClickConnMockCommon + userModule user.Module } // testDB can be injected for sharing a DB across multiple integration testbeds. @@ -363,8 +367,10 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI reader, mockClickhouse := NewMockClickhouseReader(t, testDB) mockClickhouse.MatchExpectationsInOrder(false) - modules := signoz.NewModules(testDB) - handlers := signoz.NewHandlers(modules) + userModule := impluser.NewModule(impluser.NewStore(testDB)) + userHandler := impluser.NewHandler(userModule) + modules := signoz.NewModules(testDB, userModule) + handlers := signoz.NewHandlers(modules, userHandler) quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ @@ -390,7 +396,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI apiHandler.RegisterCloudIntegrationsRoutes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule) + user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -400,6 +406,7 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, + userModule: userModule, } } @@ -556,7 +563,7 @@ func (tb *CloudIntegrationsTestBed) RequestQS( postData interface{}, ) (responseDataJson []byte) { req, err := AuthenticatedRequestForTest( - tb.testUser, path, postData, + tb.userModule, tb.testUser, path, postData, ) if err != nil { tb.t.Fatalf("couldn't create authenticated test request: %v", err) diff --git a/pkg/query-service/tests/integration/signoz_integrations_test.go b/pkg/query-service/tests/integration/signoz_integrations_test.go index 3c020271300f..d239298d123f 100644 --- a/pkg/query-service/tests/integration/signoz_integrations_test.go +++ b/pkg/query-service/tests/integration/signoz_integrations_test.go @@ -3,16 +3,19 @@ package tests import ( "encoding/json" "fmt" - "github.com/SigNoz/signoz/pkg/modules/quickfilter" - quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" "net/http" "slices" "testing" "time" + "github.com/SigNoz/signoz/pkg/modules/quickfilter" + quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core" + "github.com/SigNoz/signoz/pkg/http/middleware" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" + "github.com/SigNoz/signoz/pkg/modules/user" + "github.com/SigNoz/signoz/pkg/modules/user/impluser" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations" "github.com/SigNoz/signoz/pkg/query-service/app/integrations" @@ -374,6 +377,7 @@ type IntegrationsTestBed struct { testUser *types.User qsHttpHandler http.Handler mockClickhouse mockhouse.ClickConnMockCommon + userModule user.Module } func (tb *IntegrationsTestBed) GetAvailableIntegrationsFromQS() *integrations.IntegrationsListResponse { @@ -506,7 +510,7 @@ func (tb *IntegrationsTestBed) RequestQS( postData interface{}, ) *app.ApiResponse { req, err := AuthenticatedRequestForTest( - tb.testUser, path, postData, + tb.userModule, tb.testUser, path, postData, ) if err != nil { tb.t.Fatalf("couldn't create authenticated test request: %v", err) @@ -569,8 +573,11 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration t.Fatalf("could not create cloud integrations controller: %v", err) } - modules := signoz.NewModules(testDB) - handlers := signoz.NewHandlers(modules) + userModule := impluser.NewModule(impluser.NewStore(testDB)) + userHandler := impluser.NewHandler(userModule) + modules := signoz.NewModules(testDB, userModule) + handlers := signoz.NewHandlers(modules, userHandler) + quickFilterModule := quickfilter.NewAPI(quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(testDB))) apiHandler, err := app.NewAPIHandler(app.APIHandlerOpts{ @@ -597,7 +604,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration apiHandler.RegisterIntegrationRoutes(router, am) organizationModule := implorganization.NewModule(implorganization.NewStore(testDB)) - user, apiErr := createTestUser(organizationModule) + user, apiErr := createTestUser(organizationModule, userModule) if apiErr != nil { t.Fatalf("could not create a test user: %v", apiErr) } @@ -607,6 +614,7 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration testUser: user, qsHttpHandler: router, mockClickhouse: mockClickhouse, + userModule: userModule, } } diff --git a/pkg/query-service/tests/integration/test_utils.go b/pkg/query-service/tests/integration/test_utils.go index 1a6e4f24c7a2..869d6eeda727 100644 --- a/pkg/query-service/tests/integration/test_utils.go +++ b/pkg/query-service/tests/integration/test_utils.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "runtime/debug" "testing" "time" @@ -15,18 +16,18 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest" "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/prometheus/prometheustest" "github.com/SigNoz/signoz/pkg/query-service/app" "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader" - "github.com/SigNoz/signoz/pkg/query-service/auth" - "github.com/SigNoz/signoz/pkg/query-service/dao" "github.com/SigNoz/signoz/pkg/query-service/model" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/telemetrystore" "github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/authtypes" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/google/uuid" "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry" mockhouse "github.com/srikanthccv/ClickHouse-go-mock" @@ -34,7 +35,7 @@ import ( "golang.org/x/exp/maps" ) -var jwt = authtypes.NewJWT("secret", 1*time.Hour, 2*time.Hour) +var jwt = authtypes.NewJWT(os.Getenv("SIGNOZ_JWT_SECRET"), 1*time.Hour, 2*time.Hour) func NewMockClickhouseReader(t *testing.T, testDB sqlstore.SQLStore) (*clickhouseReader.ClickHouseReader, mockhouse.ClickConnMockCommon) { require.NotNil(t, testDB) @@ -146,7 +147,7 @@ func makeTestSignozLog( return testLog } -func createTestUser(organizationModule organization.Module) (*types.User, *model.ApiError) { +func createTestUser(organizationModule organization.Module, userModule user.Module) (*types.User, *model.ApiError) { // Create a test user for auth ctx := context.Background() organization := types.NewOrganization("test") @@ -155,28 +156,28 @@ func createTestUser(organizationModule organization.Module) (*types.User, *model return nil, model.InternalError(err) } - userId := uuid.NewString() + userId := valuer.GenerateUUID() - return dao.DB().CreateUser( - ctx, - &types.User{ - ID: userId, - Name: "test", - Email: userId[:8] + "test@test.com", - Password: "test", - OrgID: organization.ID.StringValue(), - Role: authtypes.RoleAdmin.String(), - }, - true, - ) + user, err := types.NewUser("test", userId.String()+"test@test.com", types.RoleAdmin.String(), organization.ID.StringValue()) + if err != nil { + return nil, model.InternalError(err) + } + + err = userModule.CreateUser(ctx, user) + if err != nil { + return nil, model.InternalError(err) + } + + return user, nil } func AuthenticatedRequestForTest( + userModule user.Module, user *types.User, path string, postData interface{}, ) (*http.Request, error) { - userJwt, err := auth.GenerateJWTForUser(user, jwt) + userJwt, err := userModule.GetJWTForUser(context.Background(), user) if err != nil { return nil, err } diff --git a/pkg/query-service/utils/testutils.go b/pkg/query-service/utils/testutils.go index a1e3110f5b59..565c35a70ec2 100644 --- a/pkg/query-service/utils/testutils.go +++ b/pkg/query-service/utils/testutils.go @@ -55,10 +55,18 @@ func NewTestSqliteDB(t *testing.T) (sqlStore sqlstore.SQLStore, testDBFilePath s sqlmigration.NewDropLicensesSitesFactory(sqlStore), sqlmigration.NewUpdateInvitesFactory(sqlStore), sqlmigration.NewUpdatePatFactory(sqlStore), + sqlmigration.NewUpdateAlertmanagerFactory(sqlStore), + sqlmigration.NewUpdatePreferencesFactory(sqlStore), + sqlmigration.NewUpdateApdexTtlFactory(sqlStore), + sqlmigration.NewUpdateResetPasswordFactory(sqlStore), + sqlmigration.NewUpdateRulesFactory(sqlStore), sqlmigration.NewAddVirtualFieldsFactory(), sqlmigration.NewUpdateIntegrationsFactory(sqlStore), sqlmigration.NewUpdateOrganizationsFactory(sqlStore), sqlmigration.NewDropGroupsFactory(sqlStore), + sqlmigration.NewCreateQuickFiltersFactory(sqlStore), + sqlmigration.NewUpdateQuickFiltersFactory(sqlStore), + sqlmigration.NewAuthRefactorFactory(sqlStore), ), ) if err != nil { diff --git a/pkg/signoz/handler.go b/pkg/signoz/handler.go index 84cf78398097..e2860f7580a0 100644 --- a/pkg/signoz/handler.go +++ b/pkg/signoz/handler.go @@ -5,16 +5,19 @@ import ( "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/preference" "github.com/SigNoz/signoz/pkg/modules/preference/implpreference" + "github.com/SigNoz/signoz/pkg/modules/user" ) type Handlers struct { Organization organization.Handler Preference preference.Handler + User user.Handler } -func NewHandlers(modules Modules) Handlers { +func NewHandlers(modules Modules, user user.Handler) Handlers { return Handlers{ Organization: implorganization.NewHandler(modules.Organization), Preference: implpreference.NewHandler(modules.Preference), + User: user, } } diff --git a/pkg/signoz/module.go b/pkg/signoz/module.go index 68a3c96c0190..8501904358de 100644 --- a/pkg/signoz/module.go +++ b/pkg/signoz/module.go @@ -5,6 +5,7 @@ import ( "github.com/SigNoz/signoz/pkg/modules/organization/implorganization" "github.com/SigNoz/signoz/pkg/modules/preference" "github.com/SigNoz/signoz/pkg/modules/preference/implpreference" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types/preferencetypes" ) @@ -12,11 +13,13 @@ import ( type Modules struct { Organization organization.Module Preference preference.Module + User user.Module } -func NewModules(sqlstore sqlstore.SQLStore) Modules { +func NewModules(sqlstore sqlstore.SQLStore, user user.Module) Modules { return Modules{ Organization: implorganization.NewModule(implorganization.NewStore(sqlstore)), Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewDefaultPreferenceMap()), + User: user, } } diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 85e832760d6b..644d8ff390f0 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -76,6 +76,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM sqlmigration.NewDropGroupsFactory(sqlstore), sqlmigration.NewCreateQuickFiltersFactory(sqlstore), sqlmigration.NewUpdateQuickFiltersFactory(sqlstore), + sqlmigration.NewAuthRefactorFactory(sqlstore), ) } diff --git a/pkg/signoz/signoz.go b/pkg/signoz/signoz.go index 5ce35ef6aad2..18a8e627ddd2 100644 --- a/pkg/signoz/signoz.go +++ b/pkg/signoz/signoz.go @@ -7,6 +7,7 @@ import ( "github.com/SigNoz/signoz/pkg/cache" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/instrumentation" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/sqlmigration" "github.com/SigNoz/signoz/pkg/sqlmigrator" @@ -41,6 +42,8 @@ func New( webProviderFactories factory.NamedMap[factory.ProviderFactory[web.Web, web.Config]], sqlstoreProviderFactories factory.NamedMap[factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config]], telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]], + diModules func(sqlstore.SQLStore) user.Module, + diHandlers func(user.Module) user.Handler, ) (*SigNoz, error) { // Initialize instrumentation instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz") @@ -153,11 +156,14 @@ func New( return nil, err } + userModule := diModules(sqlstore) + userHandler := diHandlers(userModule) + // Initialize all modules - modules := NewModules(sqlstore) + modules := NewModules(sqlstore, userModule) // Initialize all handlers for the modules - handlers := NewHandlers(modules) + handlers := NewHandlers(modules, userHandler) registry, err := factory.NewRegistry( instrumentation.Logger(), diff --git a/pkg/sqlmigration/032_auth_refactor.go b/pkg/sqlmigration/032_auth_refactor.go new file mode 100644 index 000000000000..8ec5cb12a297 --- /dev/null +++ b/pkg/sqlmigration/032_auth_refactor.go @@ -0,0 +1,233 @@ +package sqlmigration + +import ( + "context" + "database/sql" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type authRefactor struct { + store sqlstore.SQLStore +} + +func NewAuthRefactorFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("auth_refactor"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { + return newAuthRefactor(ctx, ps, c, sqlstore) + }) +} + +func newAuthRefactor(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { + return &authRefactor{store: store}, nil +} + +func (migration *authRefactor) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + + return nil +} + +type existingUser32 struct { + bun.BaseModel `bun:"table:users"` + + types.TimeAuditable + ID string `bun:"id,pk,type:text" json:"id"` + Name string `bun:"name,type:text,notnull" json:"name"` + Email string `bun:"email,type:text,notnull,unique" json:"email"` + Password string `bun:"password,type:text,notnull" json:"-"` + ProfilePictureURL string `bun:"profile_picture_url,type:text" json:"profilePictureURL"` + Role string `bun:"role,type:text,notnull" json:"role"` + OrgID string `bun:"org_id,type:text,notnull" json:"orgId"` +} + +type factorPassword32 struct { + bun.BaseModel `bun:"table:factor_password"` + + types.Identifiable + types.TimeAuditable + Password string `bun:"password,type:text,notnull" json:"password"` + Temporary bool `bun:"temporary,type:boolean,notnull" json:"temporary"` + UserID string `bun:"user_id,type:text,notnull" json:"userID"` +} + +type existingResetPasswordRequest32 struct { + bun.BaseModel `bun:"table:reset_password_request"` + + types.Identifiable + Token string `bun:"token,type:text,notnull" json:"token"` + UserID string `bun:"user_id,type:text,notnull,unique" json:"userId"` +} + +type newResetPasswordRequest32 struct { + bun.BaseModel `bun:"table:reset_password_token"` + + types.Identifiable + Token string `bun:"token,type:text,notnull" json:"token"` + PasswordID string `bun:"password_id,type:text,notnull" json:"passwordID"` +} + +func (migration *authRefactor) Up(ctx context.Context, db *bun.DB) error { + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer tx.Rollback() + + if _, err := tx.NewCreateTable(). + Model(new(factorPassword32)). + ForeignKey(`("user_id") REFERENCES "users" ("id")`). + IfNotExists(). + Exec(ctx); err != nil { + return err + } + + // copy passwords from users table to factor_password table + err = migration.CopyOldPasswordToNewPassword(ctx, tx) + if err != nil { + return err + } + + // delete profile picture url + err = migration.store.Dialect().DropColumn(ctx, tx, "users", "profile_picture_url") + if err != nil { + return err + } + // delete password + err = migration.store.Dialect().DropColumn(ctx, tx, "users", "password") + if err != nil { + return err + } + + // rename name to display name + _, err = migration.store.Dialect().RenameColumn(ctx, tx, "users", "name", "display_name") + if err != nil { + return err + } + + err = migration. + store. + Dialect(). + RenameTableAndModifyModel(ctx, tx, new(existingResetPasswordRequest32), new(newResetPasswordRequest32), []string{FactorPasswordReference}, func(ctx context.Context) error { + existingRequests := make([]*existingResetPasswordRequest32, 0) + err = tx. + NewSelect(). + Model(&existingRequests). + Scan(ctx) + if err != nil { + if err != sql.ErrNoRows { + return err + } + } + + if err == nil && len(existingRequests) > 0 { + // copy users and their passwords to new table + newRequests, err := migration. + CopyOldResetPasswordToNewResetPassword(ctx, tx, existingRequests) + if err != nil { + return err + } + _, err = tx. + NewInsert(). + Model(&newRequests). + Exec(ctx) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + return nil +} + +func (migration *authRefactor) Down(context.Context, *bun.DB) error { + return nil +} + +func (migration *authRefactor) CopyOldPasswordToNewPassword(ctx context.Context, tx bun.IDB) error { + // check if data already in factor_password table + var count int64 + err := tx.NewSelect().Model(new(factorPassword32)).ColumnExpr("COUNT(*)").Scan(ctx, &count) + if err != nil { + return err + } + + if count > 0 { + return nil + } + + // check if password column exist in the users table. + exists, err := migration.store.Dialect().ColumnExists(ctx, tx, "users", "password") + if err != nil { + return err + } + if !exists { + return nil + } + + // get all users from users table + existingUsers := make([]*existingUser32, 0) + err = tx.NewSelect().Model(&existingUsers).Scan(ctx) + if err != nil { + return err + } + + newPasswords := make([]*factorPassword32, 0) + for _, user := range existingUsers { + newPasswords = append(newPasswords, &factorPassword32{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + Password: user.Password, + Temporary: false, + UserID: user.ID, + }) + } + + // insert + if len(newPasswords) > 0 { + _, err = tx.NewInsert().Model(&newPasswords).Exec(ctx) + if err != nil { + return err + } + } + + return nil +} + +func (migration *authRefactor) CopyOldResetPasswordToNewResetPassword(ctx context.Context, tx bun.IDB, existingRequests []*existingResetPasswordRequest32) ([]*newResetPasswordRequest32, error) { + newRequests := make([]*newResetPasswordRequest32, 0) + for _, request := range existingRequests { + // get password id from user id + var passwordID string + err := tx.NewSelect().Table("factor_password").Column("id").Where("user_id = ?", request.UserID).Scan(ctx, &passwordID) + if err != nil { + return nil, err + } + + newRequests = append(newRequests, &newResetPasswordRequest32{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + Token: request.Token, + PasswordID: passwordID, + }) + } + return newRequests, nil +} diff --git a/pkg/sqlmigration/sqlmigration.go b/pkg/sqlmigration/sqlmigration.go index 8741e9579860..f16a4c74c414 100644 --- a/pkg/sqlmigration/sqlmigration.go +++ b/pkg/sqlmigration/sqlmigration.go @@ -27,6 +27,7 @@ var ( var ( OrgReference = "org" UserReference = "user" + FactorPasswordReference = "factor_password" CloudIntegrationReference = "cloud_integration" ) diff --git a/pkg/sqlstore/sqlitesqlstore/dialect.go b/pkg/sqlstore/sqlitesqlstore/dialect.go index fe5ceae147b7..e326e6b92162 100644 --- a/pkg/sqlstore/sqlitesqlstore/dialect.go +++ b/pkg/sqlstore/sqlitesqlstore/dialect.go @@ -20,12 +20,14 @@ const ( const ( Org string = "org" User string = "user" + FactorPassword string = "factor_password" CloudIntegration string = "cloud_integration" ) const ( OrgReference string = `("org_id") REFERENCES "organizations" ("id")` UserReference string = `("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE` + FactorPasswordReference string = `("password_id") REFERENCES "factor_password" ("id")` CloudIntegrationReference string = `("cloud_integration_id") REFERENCES "cloud_integration" ("id") ON DELETE CASCADE` ) @@ -259,6 +261,8 @@ func (dialect *dialect) RenameTableAndModifyModel(ctx context.Context, bun bun.I fkReferences = append(fkReferences, OrgReference) } else if reference == User && !slices.Contains(fkReferences, UserReference) { fkReferences = append(fkReferences, UserReference) + } else if reference == FactorPassword && !slices.Contains(fkReferences, FactorPasswordReference) { + fkReferences = append(fkReferences, FactorPasswordReference) } else if reference == CloudIntegration && !slices.Contains(fkReferences, CloudIntegrationReference) { fkReferences = append(fkReferences, CloudIntegrationReference) } diff --git a/pkg/types/authtypes/claims.go b/pkg/types/authtypes/claims.go index 3ce3d79b430f..f46fd073d511 100644 --- a/pkg/types/authtypes/claims.go +++ b/pkg/types/authtypes/claims.go @@ -5,6 +5,7 @@ import ( "slices" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types" "github.com/golang-jwt/jwt/v5" ) @@ -12,10 +13,10 @@ var _ jwt.ClaimsValidator = (*Claims)(nil) type Claims struct { jwt.RegisteredClaims - UserID string `json:"id"` - Email string `json:"email"` - Role Role `json:"role"` - OrgID string `json:"orgId"` + UserID string `json:"id"` + Email string `json:"email"` + Role types.Role `json:"role"` + OrgID string `json:"orgId"` } func (c *Claims) Validate() error { @@ -47,7 +48,7 @@ func (c *Claims) LogValue() slog.Value { } func (c *Claims) IsViewer() error { - if slices.Contains([]Role{RoleViewer, RoleEditor, RoleAdmin}, c.Role) { + if slices.Contains([]types.Role{types.RoleViewer, types.RoleEditor, types.RoleAdmin}, c.Role) { return nil } @@ -55,7 +56,7 @@ func (c *Claims) IsViewer() error { } func (c *Claims) IsEditor() error { - if slices.Contains([]Role{RoleEditor, RoleAdmin}, c.Role) { + if slices.Contains([]types.Role{types.RoleEditor, types.RoleAdmin}, c.Role) { return nil } @@ -63,7 +64,7 @@ func (c *Claims) IsEditor() error { } func (c *Claims) IsAdmin() error { - if c.Role == RoleAdmin { + if c.Role == types.RoleAdmin { return nil } @@ -75,7 +76,7 @@ func (c *Claims) IsSelfAccess(id string) error { return nil } - if c.Role == RoleAdmin { + if c.Role == types.RoleAdmin { return nil } diff --git a/pkg/types/authtypes/jwt.go b/pkg/types/authtypes/jwt.go index 5b31584a89ee..6bf54a178269 100644 --- a/pkg/types/authtypes/jwt.go +++ b/pkg/types/authtypes/jwt.go @@ -6,6 +6,7 @@ import ( "time" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types" "github.com/golang-jwt/jwt/v5" ) @@ -81,7 +82,7 @@ func (j *JWT) signToken(claims Claims) (string, error) { } // AccessToken creates an access token with the provided claims -func (j *JWT) AccessToken(orgId, userId, email string, role Role) (string, Claims, error) { +func (j *JWT) AccessToken(orgId, userId, email string, role types.Role) (string, Claims, error) { claims := Claims{ UserID: userId, Role: role, @@ -102,7 +103,7 @@ func (j *JWT) AccessToken(orgId, userId, email string, role Role) (string, Claim } // RefreshToken creates a refresh token with the provided claims -func (j *JWT) RefreshToken(orgId, userId, email string, role Role) (string, Claims, error) { +func (j *JWT) RefreshToken(orgId, userId, email string, role types.Role) (string, Claims, error) { claims := Claims{ UserID: userId, Role: role, diff --git a/pkg/types/authtypes/jwt_test.go b/pkg/types/authtypes/jwt_test.go index b98aa3144da2..d0f33dd24c2a 100644 --- a/pkg/types/authtypes/jwt_test.go +++ b/pkg/types/authtypes/jwt_test.go @@ -5,13 +5,14 @@ import ( "time" "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/types" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" ) func TestJwtAccessToken(t *testing.T) { jwtService := NewJWT("secret", time.Minute, time.Hour) - token, _, err := jwtService.AccessToken("orgId", "userId", "email@example.com", RoleAdmin) + token, _, err := jwtService.AccessToken("orgId", "userId", "email@example.com", types.RoleAdmin) assert.NoError(t, err) assert.NotEmpty(t, token) @@ -19,7 +20,7 @@ func TestJwtAccessToken(t *testing.T) { func TestJwtRefreshToken(t *testing.T) { jwtService := NewJWT("secret", time.Minute, time.Hour) - token, _, err := jwtService.RefreshToken("orgId", "userId", "email@example.com", RoleAdmin) + token, _, err := jwtService.RefreshToken("orgId", "userId", "email@example.com", types.RoleAdmin) assert.NoError(t, err) assert.NotEmpty(t, token) @@ -31,7 +32,7 @@ func TestJwtClaims(t *testing.T) { // Create a valid token claims := Claims{ UserID: "userId", - Role: RoleAdmin, + Role: types.RoleAdmin, Email: "email@example.com", OrgID: "orgId", RegisteredClaims: jwt.RegisteredClaims{ @@ -65,7 +66,7 @@ func TestJwtClaimsExpiredToken(t *testing.T) { // Create an expired token claims := Claims{ UserID: "userId", - Role: RoleAdmin, + Role: types.RoleAdmin, Email: "email@example.com", OrgID: "orgId", RegisteredClaims: jwt.RegisteredClaims{ @@ -87,7 +88,7 @@ func TestJwtClaimsInvalidSignature(t *testing.T) { // Create a valid token claims := Claims{ UserID: "userId", - Role: RoleAdmin, + Role: types.RoleAdmin, Email: "email@example.com", OrgID: "orgId", RegisteredClaims: jwt.RegisteredClaims{ @@ -131,7 +132,7 @@ func TestJwtClaimsMissingUserID(t *testing.T) { claims := Claims{ UserID: "", - Role: RoleAdmin, + Role: types.RoleAdmin, Email: "email@example.com", OrgID: "orgId", RegisteredClaims: jwt.RegisteredClaims{ @@ -171,7 +172,7 @@ func TestJwtClaimsMissingOrgID(t *testing.T) { claims := Claims{ UserID: "userId", - Role: RoleAdmin, + Role: types.RoleAdmin, Email: "email@example.com", OrgID: "", RegisteredClaims: jwt.RegisteredClaims{ diff --git a/ee/types/domain.go b/pkg/types/domain.go similarity index 91% rename from ee/types/domain.go rename to pkg/types/domain.go index 5cba2223020b..3134c0fe3e45 100644 --- a/ee/types/domain.go +++ b/pkg/types/domain.go @@ -6,9 +6,7 @@ import ( "net/url" "strings" - "github.com/SigNoz/signoz/ee/query-service/sso" - "github.com/SigNoz/signoz/ee/query-service/sso/saml" - "github.com/SigNoz/signoz/pkg/types" + "github.com/SigNoz/signoz/pkg/types/ssotypes" "github.com/google/uuid" "github.com/pkg/errors" saml2 "github.com/russellhaering/gosaml2" @@ -19,7 +17,7 @@ import ( type StorableOrgDomain struct { bun.BaseModel `bun:"table:org_domains"` - types.TimeAuditable + TimeAuditable ID uuid.UUID `json:"id" bun:"id,pk,type:text"` OrgID string `json:"orgId" bun:"org_id,type:text,notnull"` Name string `json:"name" bun:"name,type:varchar(50),notnull,unique"` @@ -40,10 +38,10 @@ type GettableOrgDomain struct { SsoEnabled bool `json:"ssoEnabled"` SsoType SSOType `json:"ssoType"` - SamlConfig *SamlConfig `json:"samlConfig"` - GoogleAuthConfig *GoogleOAuthConfig `json:"googleAuthConfig"` + SamlConfig *ssotypes.SamlConfig `json:"samlConfig"` + GoogleAuthConfig *ssotypes.GoogleOAuthConfig `json:"googleAuthConfig"` - Org *types.Organization + Org *Organization } func (od *GettableOrgDomain) String() string { @@ -112,7 +110,7 @@ func (od *GettableOrgDomain) GetSAMLCert() string { // PrepareGoogleOAuthProvider creates GoogleProvider that is used in // requesting OAuth and also used in processing response from google -func (od *GettableOrgDomain) PrepareGoogleOAuthProvider(siteUrl *url.URL) (sso.OAuthCallbackProvider, error) { +func (od *GettableOrgDomain) PrepareGoogleOAuthProvider(siteUrl *url.URL) (ssotypes.OAuthCallbackProvider, error) { if od.GoogleAuthConfig == nil { return nil, fmt.Errorf("GOOGLE OAUTH is not setup correctly for this domain") } @@ -143,7 +141,7 @@ func (od *GettableOrgDomain) PrepareSamlRequest(siteUrl *url.URL) (*saml2.SAMLSe // currently we default it to host from window.location (received from browser) issuer := siteUrl.Host - return saml.PrepareRequest(issuer, acs, sourceUrl, od.GetSAMLEntityID(), od.GetSAMLIdpURL(), od.GetSAMLCert()) + return ssotypes.PrepareRequest(issuer, acs, sourceUrl, od.GetSAMLEntityID(), od.GetSAMLIdpURL(), od.GetSAMLCert()) } func (od *GettableOrgDomain) BuildSsoUrl(siteUrl *url.URL) (ssoUrl string, err error) { diff --git a/pkg/types/invite.go b/pkg/types/invite.go new file mode 100644 index 000000000000..40fea73d27c7 --- /dev/null +++ b/pkg/types/invite.go @@ -0,0 +1,87 @@ +package types + +import ( + "fmt" + "strings" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" + "github.com/uptrace/bun" +) + +var ( + ErrInviteAlreadyExists = errors.MustNewCode("invite_already_exists") + ErrInviteNotFound = errors.MustNewCode("invite_not_found") +) + +type GettableEEInvite struct { + GettableInvite + PreCheck *GettableLoginPrecheck `bun:"-" json:"precheck"` +} + +type GettableInvite struct { + Invite + Organization string `bun:"organization,type:text,notnull" json:"organization"` +} + +type Invite struct { + bun.BaseModel `bun:"table:user_invite"` + + Identifiable + TimeAuditable + OrgID string `bun:"org_id,type:text,notnull" json:"orgID"` + Name string `bun:"name,type:text,notnull" json:"name"` + Email string `bun:"email,type:text,notnull,unique" json:"email"` + Token string `bun:"token,type:text,notnull" json:"token"` + Role string `bun:"role,type:text,notnull" json:"role"` + + InviteLink string `bun:"-" json:"inviteLink"` +} + +func NewInvite(orgID, role, name, email string) (*Invite, error) { + if email == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required") + } + _, err := NewRole(role) + if err != nil { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("invalid role for user: %s", email)) + } + + email = strings.TrimSpace(email) + + invite := &Invite{ + Identifiable: Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: TimeAuditable{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + Name: name, + Email: email, + Token: valuer.GenerateUUID().String(), + Role: role, + OrgID: orgID, + } + + return invite, nil +} + +type InviteEmailData struct { + CustomerName string + InviterName string + InviterEmail string + Link string +} + +type PostableInvite struct { + Name string `json:"name"` + Email string `json:"email"` + Role Role `json:"role"` + FrontendBaseUrl string `json:"frontendBaseUrl"` +} + +type PostableBulkInviteRequest struct { + Invites []PostableInvite `json:"invites"` +} diff --git a/pkg/types/authtypes/role.go b/pkg/types/role.go similarity index 98% rename from pkg/types/authtypes/role.go rename to pkg/types/role.go index 16ac7fc6cbf9..959939bb7c2e 100644 --- a/pkg/types/authtypes/role.go +++ b/pkg/types/role.go @@ -1,4 +1,4 @@ -package authtypes +package types import ( "encoding/json" diff --git a/ee/query-service/sso/google.go b/pkg/types/ssotypes/google.go similarity index 87% rename from ee/query-service/sso/google.go rename to pkg/types/ssotypes/google.go index a27a38eb1efe..623c5c3d4b50 100644 --- a/ee/query-service/sso/google.go +++ b/pkg/types/ssotypes/google.go @@ -1,34 +1,34 @@ -package sso +package ssotypes import ( - "fmt" - "errors" "context" + "errors" + "fmt" "net/http" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) type GoogleOAuthProvider struct { - RedirectURI string - OAuth2Config *oauth2.Config - Verifier *oidc.IDTokenVerifier - Cancel context.CancelFunc - HostedDomain string + RedirectURI string + OAuth2Config *oauth2.Config + Verifier *oidc.IDTokenVerifier + Cancel context.CancelFunc + HostedDomain string } - func (g *GoogleOAuthProvider) BuildAuthURL(state string) (string, error) { var opts []oauth2.AuthCodeOption - + // set hosted domain. google supports multiple hosted domains but in our case - // we have one config per host domain. + // we have one config per host domain. opts = append(opts, oauth2.SetAuthURLParam("hd", g.HostedDomain)) return g.OAuth2Config.AuthCodeURL(state, opts...), nil } -type oauth2Error struct{ +type oauth2Error struct { error string errorDescription string } @@ -54,7 +54,6 @@ func (g *GoogleOAuthProvider) HandleCallback(r *http.Request) (identity *SSOIden return g.createIdentity(r.Context(), token) } - func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2.Token) (identity *SSOIdentity, err error) { rawIDToken, ok := token.Extra("id_token").(string) if !ok { @@ -76,7 +75,7 @@ func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2. } if claims.HostedDomain != g.HostedDomain { - return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain) + return identity, fmt.Errorf("oidc: unexpected hd claim %v", claims.HostedDomain) } identity = &SSOIdentity{ @@ -89,4 +88,3 @@ func (g *GoogleOAuthProvider) createIdentity(ctx context.Context, token *oauth2. return identity, nil } - diff --git a/ee/query-service/sso/saml/request.go b/pkg/types/ssotypes/saml.go similarity index 99% rename from ee/query-service/sso/saml/request.go rename to pkg/types/ssotypes/saml.go index 73851ef82831..34c5a7fc754f 100644 --- a/ee/query-service/sso/saml/request.go +++ b/pkg/types/ssotypes/saml.go @@ -1,4 +1,4 @@ -package saml +package ssotypes import ( "crypto/x509" diff --git a/ee/types/sso.go b/pkg/types/ssotypes/sso.go similarity index 56% rename from ee/types/sso.go rename to pkg/types/ssotypes/sso.go index 9ad640cd75bb..009ef22ca9ad 100644 --- a/ee/types/sso.go +++ b/pkg/types/ssotypes/sso.go @@ -1,15 +1,41 @@ -package types +package ssotypes import ( "context" "fmt" + "net/http" "net/url" - "github.com/SigNoz/signoz/ee/query-service/sso" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) +// SSOIdentity contains details of user received from SSO provider +type SSOIdentity struct { + UserID string + Username string + PreferredUsername string + Email string + EmailVerified bool + ConnectorData []byte +} + +// OAuthCallbackProvider is an interface implemented by connectors which use an OAuth +// style redirect flow to determine user information. +type OAuthCallbackProvider interface { + // The initial URL user would be redirect to. + // OAuth2 implementations support various scopes but we only need profile and user as + // the roles are still being managed in SigNoz. + BuildAuthURL(state string) (string, error) + + // Handle the callback to the server (after login at oauth provider site) + // and return a email identity. + // At the moment we dont support auto signup flow (based on domain), so + // the full identity (including name, group etc) is not required outside of the + // connector + HandleCallback(r *http.Request) (identity *SSOIdentity, err error) +} + type SamlConfig struct { SamlEntity string `json:"samlEntity"` SamlIdp string `json:"samlIdp"` @@ -27,7 +53,7 @@ const ( googleIssuerURL = "https://accounts.google.com" ) -func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (sso.OAuthCallbackProvider, error) { +func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (OAuthCallbackProvider, error) { ctx, cancel := context.WithCancel(context.Background()) @@ -47,7 +73,7 @@ func (g *GoogleOAuthConfig) GetProvider(domain string, siteUrl *url.URL) (sso.OA siteUrl.Host, "api/v1/complete/google") - return &sso.GoogleOAuthProvider{ + return &GoogleOAuthProvider{ RedirectURI: g.RedirectURI, OAuth2Config: &oauth2.Config{ ClientID: g.ClientID, diff --git a/pkg/types/user.go b/pkg/types/user.go index 21932af14867..b5d297cb2688 100644 --- a/pkg/types/user.go +++ b/pkg/types/user.go @@ -1,19 +1,63 @@ package types import ( + "context" + "strings" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" ) -type Invite struct { - bun.BaseModel `bun:"table:user_invite"` +const ( + SSOAvailable = "sso_available" +) - Identifiable - TimeAuditable - OrgID string `bun:"org_id,type:text,notnull" json:"orgId"` - Name string `bun:"name,type:text,notnull" json:"name"` - Email string `bun:"email,type:text,notnull,unique" json:"email"` - Token string `bun:"token,type:text,notnull" json:"token"` - Role string `bun:"role,type:text,notnull" json:"role"` +var ( + ErrUserAlreadyExists = errors.MustNewCode("user_already_exists") + ErrPasswordAlreadyExists = errors.MustNewCode("password_already_exists") + ErrUserNotFound = errors.MustNewCode("user_not_found") + ErrResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists") + ErrPasswordNotFound = errors.MustNewCode("password_not_found") + ErrResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found") +) + +type UserStore interface { + // invite + CreateBulkInvite(ctx context.Context, invites []*Invite) error + ListInvite(ctx context.Context, orgID string) ([]*Invite, error) + DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error + GetInviteByToken(ctx context.Context, token string) (*GettableInvite, error) + GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*Invite, error) + + // user + CreateUserWithPassword(ctx context.Context, user *User, password *FactorPassword) (*User, error) + CreateUser(ctx context.Context, user *User) error + GetUserByID(ctx context.Context, orgID string, id string) (*GettableUser, error) + GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*GettableUser, error) + GetUsersByEmail(ctx context.Context, email string) ([]*GettableUser, error) + GetUsersByRoleInOrg(ctx context.Context, orgID string, role Role) ([]*GettableUser, error) + ListUsers(ctx context.Context, orgID string) ([]*GettableUser, error) + UpdateUser(ctx context.Context, orgID string, id string, user *User) (*User, error) + DeleteUser(ctx context.Context, orgID string, id string) error + + // password + CreatePassword(ctx context.Context, password *FactorPassword) (*FactorPassword, error) + CreateResetPasswordToken(ctx context.Context, resetPasswordRequest *ResetPasswordRequest) error + GetPasswordByID(ctx context.Context, id string) (*FactorPassword, error) + GetPasswordByUserID(ctx context.Context, id string) (*FactorPassword, error) + GetResetPassword(ctx context.Context, token string) (*ResetPasswordRequest, error) + GetResetPasswordByPasswordID(ctx context.Context, passwordID string) (*ResetPasswordRequest, error) + UpdatePassword(ctx context.Context, userID string, password string) error + UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, userID string, password string) error + + // Auth Domain + GetDomainByName(ctx context.Context, name string) (*StorableOrgDomain, error) + + // Temporary func for SSO + GetDefaultOrgID(ctx context.Context) (string, error) } type GettableUser struct { @@ -24,19 +68,173 @@ type GettableUser struct { type User struct { bun.BaseModel `bun:"table:users"` + Identifiable TimeAuditable - ID string `bun:"id,pk,type:text" json:"id"` - Name string `bun:"name,type:text,notnull" json:"name"` - Email string `bun:"email,type:text,notnull,unique" json:"email"` - Password string `bun:"password,type:text,notnull" json:"-"` - ProfilePictureURL string `bun:"profile_picture_url,type:text" json:"profilePictureURL"` - Role string `bun:"role,type:text,notnull" json:"role"` - OrgID string `bun:"org_id,type:text,notnull" json:"orgId"` + DisplayName string `bun:"display_name,type:text,notnull" json:"displayName"` + Email string `bun:"email,type:text,notnull,unique:org_email" json:"email"` + Role string `bun:"role,type:text,notnull" json:"role"` + OrgID string `bun:"org_id,type:text,notnull,unique:org_email,references:org(id),on_delete:CASCADE" json:"orgId"` +} + +func NewUser(displayName string, email string, role string, orgID string) (*User, error) { + if email == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required") + } + + if role == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "role is required") + } + + if orgID == "" { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required") + } + + return &User{ + Identifiable: Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: TimeAuditable{ + CreatedAt: time.Now(), + }, + DisplayName: displayName, + Email: email, + Role: role, + OrgID: orgID, + }, nil +} + +type PostableRegisterOrgAndAdmin struct { + PostableAcceptInvite + Name string `json:"name"` + OrgID string `json:"orgId"` + OrgDisplayName string `json:"orgDisplayName"` + OrgName string `json:"orgName"` + Email string `json:"email"` +} + +type PostableAcceptInvite struct { + DisplayName string `json:"displayName"` + InviteToken string `json:"token"` + Password string `json:"password"` + + // reference URL to track where the register request is coming from + SourceURL string `json:"sourceUrl"` +} + +func (p *PostableAcceptInvite) Validate() error { + if p.InviteToken == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invite token is required") + } + + if p.Password == "" || len(p.Password) < 8 { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password must be at least 8 characters long") + } + + return nil +} + +type FactorPassword struct { + bun.BaseModel `bun:"table:factor_password"` + + Identifiable + TimeAuditable + Password string `bun:"password,type:text,notnull" json:"password"` + Temporary bool `bun:"temporary,type:boolean,notnull" json:"temporary"` + UserID string `bun:"user_id,type:text,notnull,unique,references:user(id)" json:"userId"` +} + +func NewFactorPassword(password string) (*FactorPassword, error) { + + if password == "" && len(password) < 8 { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "password must be at least 8 characters long") + } + + password = strings.TrimSpace(password) + + hashedPassword, err := HashPassword(password) + if err != nil { + return nil, err + } + + return &FactorPassword{ + Identifiable: Identifiable{ + ID: valuer.GenerateUUID(), + }, + TimeAuditable: TimeAuditable{ + CreatedAt: time.Now(), + }, + Password: hashedPassword, + Temporary: false, + }, nil +} + +func HashPassword(password string) (string, error) { + // bcrypt automatically handles salting and uses a secure work factor + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedPassword), nil +} + +func ComparePassword(hashedPassword, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil } type ResetPasswordRequest struct { - bun.BaseModel `bun:"table:reset_password_request"` + bun.BaseModel `bun:"table:reset_password_token"` + Identifiable - Token string `bun:"token,type:text,notnull" json:"token"` - UserID string `bun:"user_id,type:text,notnull" json:"userId"` + Token string `bun:"token,type:text,notnull" json:"token"` + PasswordID string `bun:"password_id,type:text,notnull,unique,references:factor_password(id)" json:"passwordId"` +} + +func NewResetPasswordRequest(passwordID string) (*ResetPasswordRequest, error) { + return &ResetPasswordRequest{ + Identifiable: Identifiable{ + ID: valuer.GenerateUUID(), + }, + Token: valuer.GenerateUUID().String(), + PasswordID: passwordID, + }, nil +} + +type PostableResetPassword struct { + Password string `json:"password"` + Token string `json:"token"` +} + +type ChangePasswordRequest struct { + UserId string `json:"userId"` + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` +} + +type PostableLoginRequest struct { + OrgID string `json:"orgId"` + Email string `json:"email"` + Password string `json:"password"` + RefreshToken string `json:"refreshToken"` +} + +type GettableUserJwt struct { + AccessJwt string `json:"accessJwt"` + AccessJwtExpiry int64 `json:"accessJwtExpiry"` + RefreshJwt string `json:"refreshJwt"` + RefreshJwtExpiry int64 `json:"refreshJwtExpiry"` +} + +type GettableLoginResponse struct { + GettableUserJwt + UserID string `json:"userId"` +} + +type GettableLoginPrecheck struct { + SSO bool `json:"sso"` + SSOUrl string `json:"ssoUrl"` + CanSelfRegister bool `json:"canSelfRegister"` + IsUser bool `json:"isUser"` + SSOError string `json:"ssoError"` + SelectOrg bool `json:"selectOrg"` + Orgs []string `json:"orgs"` } diff --git a/tests/integration/fixtures/auth.py b/tests/integration/fixtures/auth.py index 665f094c8325..6ac43b1924dc 100644 --- a/tests/integration/fixtures/auth.py +++ b/tests/integration/fixtures/auth.py @@ -39,6 +39,6 @@ def get_jwt_token(signoz: types.SigNoz) -> str: ) assert response.status_code == HTTPStatus.OK - return response.json()["accessJwt"] + return response.json()["data"]["accessJwt"] return _get_jwt_token diff --git a/tests/integration/src/bootstrap/b_register.py b/tests/integration/src/bootstrap/b_register.py index a2d3d91fa634..cea3ef27c92f 100644 --- a/tests/integration/src/bootstrap/b_register.py +++ b/tests/integration/src/bootstrap/b_register.py @@ -42,7 +42,7 @@ def test_register(signoz: types.SigNoz, get_jwt_token) -> None: assert response.status_code == HTTPStatus.OK - user_response = response.json() + user_response = response.json()["data"] found_user = next( (user for user in user_response if user["email"] == "admin@integration.test"), None, @@ -52,13 +52,13 @@ def test_register(signoz: types.SigNoz, get_jwt_token) -> None: assert found_user["role"] == "ADMIN" response = requests.get( - signoz.self.host_config.get(f"/api/v1/rbac/role/{found_user["id"]}"), + signoz.self.host_config.get(f"/api/v1/user/{found_user["id"]}"), timeout=2, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == HTTPStatus.OK - assert response.json()["group_name"] == "ADMIN" + assert response.json()["data"]["role"] == "ADMIN" def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None: @@ -72,22 +72,29 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None: }, ) - assert response.status_code == HTTPStatus.OK + assert response.status_code == HTTPStatus.CREATED - invite_response = response.json() - assert "email" in invite_response - assert "inviteToken" in invite_response + response = requests.get( + signoz.self.host_config.get("/api/v1/invite"), + timeout=2, + headers={ + "Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}" # pylint: disable=line-too-long + }, + ) - assert invite_response["email"] == "editor@integration.test" + invite_response = response.json()["data"] + found_invite = next( + (invite for invite in invite_response if invite["email"] == "editor@integration.test"), + None, + ) # Register the editor user using the invite token response = requests.post( - signoz.self.host_config.get("/api/v1/register"), + signoz.self.host_config.get("/api/v1/invite/accept"), json={ - "email": "editor@integration.test", "password": "password", - "name": "editor", - "token": f"{invite_response["inviteToken"]}", + "displayName": "editor", + "token": f"{found_invite['token']}", }, timeout=2, ) @@ -96,7 +103,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None: # Verify that the invite token has been deleted response = requests.get( signoz.self.host_config.get( - f"/api/v1/invite/{invite_response["inviteToken"]}" + f"/api/v1/invite/{found_invite['token']}" ), # pylint: disable=line-too-long timeout=2, ) @@ -125,7 +132,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None: assert response.status_code == HTTPStatus.OK - user_response = response.json() + user_response = response.json()["data"] found_user = next( (user for user in user_response if user["email"] == "editor@integration.test"), None, @@ -133,7 +140,7 @@ def test_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None: assert found_user is not None assert found_user["role"] == "EDITOR" - assert found_user["name"] == "editor" + assert found_user["displayName"] == "editor" assert found_user["email"] == "editor@integration.test" @@ -149,28 +156,37 @@ def test_revoke_invite_and_register(signoz: types.SigNoz, get_jwt_token) -> None }, ) - assert response.status_code == HTTPStatus.OK + assert response.status_code == HTTPStatus.CREATED - invite_response = response.json() - assert "email" in invite_response - assert "inviteToken" in invite_response + response = requests.get( + signoz.self.host_config.get("/api/v1/invite"), + timeout=2, + headers={ + "Authorization": f"Bearer {get_jwt_token("admin@integration.test", "password")}" # pylint: disable=line-too-long + }, + ) + + invite_response = response.json()["data"] + found_invite = next( + (invite for invite in invite_response if invite["email"] == "viewer@integration.test"), + None, + ) response = requests.delete( - signoz.self.host_config.get(f"/api/v1/invite/{invite_response['email']}"), + signoz.self.host_config.get(f"/api/v1/invite/{found_invite['id']}"), timeout=2, headers={"Authorization": f"Bearer {admin_token}"}, ) - assert response.status_code == HTTPStatus.OK + assert response.status_code == HTTPStatus.NO_CONTENT # Try registering the viewer user with the invite token response = requests.post( - signoz.self.host_config.get("/api/v1/register"), + signoz.self.host_config.get("/api/v1/invite/accept"), json={ - "email": "viewer@integration.test", "password": "password", - "name": "viewer", - "token": f"{invite_response["inviteToken"]}", + "displayName": "viewer", + "token": f"{found_invite["token"]}", }, timeout=2, ) @@ -189,17 +205,17 @@ def test_self_access(signoz: types.SigNoz, get_jwt_token) -> None: assert response.status_code == HTTPStatus.OK - user_response = response.json() + user_response = response.json()["data"] found_user = next( (user for user in user_response if user["email"] == "editor@integration.test"), None, ) response = requests.get( - signoz.self.host_config.get(f"/api/v1/rbac/role/{found_user['id']}"), + signoz.self.host_config.get(f"/api/v1/user/{found_user['id']}"), timeout=2, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == HTTPStatus.OK - assert response.json()["group_name"] == "EDITOR" + assert response.json()["data"]["role"] == "EDITOR" diff --git a/tests/integration/src/bootstrap/d_apikey.py b/tests/integration/src/bootstrap/d_apikey.py index d2fe4f06e8d3..54470494df46 100644 --- a/tests/integration/src/bootstrap/d_apikey.py +++ b/tests/integration/src/bootstrap/d_apikey.py @@ -33,7 +33,7 @@ def test_api_key(signoz: types.SigNoz, get_jwt_token) -> None: user_response = response.json() found_user = next( - (user for user in user_response if user["email"] == "admin@integration.test"), + (user for user in user_response["data"] if user["email"] == "admin@integration.test"), None, )