chore(auth): refactor the auth modules and handler in preparation for multi tenant login (#7778)

* chore: update auth

* chore: password changes

* chore: make changes in oss code

* chore: login

* chore: get to a running state

* fix: migration inital commit

* fix: signoz cloud intgtn tests

* fix: minor fixes

* chore: sso code fixed with org domain

* fix: tests

* fix: ee auth api's

* fix: changes in name

* fix: return user in login api

* fix: address comments

* fix: validate password

* fix: handle get domain by email properly

* fix: move authomain to usermodule

* fix: use displayname instead of hname

* fix: rename back endpoints

* fix: update telemetry

* fix: correct errors

* fix: test and fix the invite endpoints

* fix: delete all things related to user in store

* fix: address issues

* fix: ee delete invite

* fix: rename func

* fix: update user and update role

* fix: update role

* fix: login and invite changes

* fix: return org name in users response

* fix: update user role

* fix: nil check

* fix: getinvite and update role

* fix: sso

* fix: getinvite use sso ctx

* fix: use correct sourceurl

* fix: getsourceurl from req payload

* fix: update created_at

* fix: fix reset password

* fix: sso signup and token password change

* fix: don't delete last admin

* fix: reset password and migration

* fix: migration

* fix: reset password for sso users

* fix: clean up invite

* fix: migration

* fix: update claims and store code

* fix: use correct error

* fix: proper nil checks

* fix: make migration multitenant

* fix: address comments

* fix: minor fixes

* fix: test

* fix: rename reset password

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
This commit is contained in:
Nityananda Gohain 2025-05-14 23:12:55 +05:30 committed by GitHub
parent 16938c6cc0
commit 0a2b7ca1d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 2986 additions and 2373 deletions

View File

@ -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,

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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"
)

View File

@ -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
}

View File

@ -33,3 +33,7 @@ func NewDataConnector(
ClickHouseReader: chReader,
}
}
func (r *ClickhouseReader) GetSQLStore() sqlstore.SQLStore {
return r.appdb
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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,
}

View File

@ -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))

View File

@ -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"`
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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")
}

75
pkg/modules/user/user.go Normal file
View File

@ -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)
}

View File

@ -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,

View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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{}

View File

@ -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

View File

@ -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{

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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))

View File

@ -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"`
}

View File

@ -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() {

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -76,6 +76,7 @@ func NewSQLMigrationProviderFactories(sqlstore sqlstore.SQLStore) factory.NamedM
sqlmigration.NewDropGroupsFactory(sqlstore),
sqlmigration.NewCreateQuickFiltersFactory(sqlstore),
sqlmigration.NewUpdateQuickFiltersFactory(sqlstore),
sqlmigration.NewAuthRefactorFactory(sqlstore),
)
}

View File

@ -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(),

View File

@ -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
}

View File

@ -27,6 +27,7 @@ var (
var (
OrgReference = "org"
UserReference = "user"
FactorPasswordReference = "factor_password"
CloudIntegrationReference = "cloud_integration"
)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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,

View File

@ -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{

View File

@ -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) {

87
pkg/types/invite.go Normal file
View File

@ -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"`
}

View File

@ -1,4 +1,4 @@
package authtypes
package types
import (
"encoding/json"

View File

@ -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
}

View File

@ -1,4 +1,4 @@
package saml
package ssotypes
import (
"crypto/x509"

View File

@ -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,

View File

@ -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"`
}

View File

@ -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

View File

@ -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"

View File

@ -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,
)