From fc4b55cb34b48fd3f47719be6ad6008b42d7e77d Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Thu, 17 Oct 2024 18:41:31 +0530 Subject: [PATCH] feat: bulk invite user api (#6057) --- pkg/query-service/app/http_handler.go | 27 ++++++ pkg/query-service/app/parser.go | 40 +++++++++ pkg/query-service/auth/auth.go | 117 ++++++++++++++++++++++++++ pkg/query-service/model/auth.go | 28 ++++++ 4 files changed, 212 insertions(+) diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 2b54bd7cce..fea82faf4c 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -486,6 +486,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *AuthMiddleware) { // === 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) @@ -1976,6 +1977,32 @@ func (aH *APIHandler) inviteUser(w http.ResponseWriter, r *http.Request) { 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.StatusInternalServerError) + } 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) { diff --git a/pkg/query-service/app/parser.go b/pkg/query-service/app/parser.go index 9ae242c19f..b8df9be9d6 100644 --- a/pkg/query-service/app/parser.go +++ b/pkg/query-service/app/parser.go @@ -22,6 +22,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/app/queryBuilder" "go.signoz.io/signoz/pkg/query-service/auth" "go.signoz.io/signoz/pkg/query-service/common" + "go.signoz.io/signoz/pkg/query-service/constants" baseconstants "go.signoz.io/signoz/pkg/query-service/constants" "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" @@ -724,6 +725,45 @@ func parseInviteRequest(r *http.Request) (*model.InviteRequest, error) { return &req, nil } +func isValidRole(role string) bool { + switch role { + case constants.AdminGroup, constants.EditorGroup, constants.ViewerGroup: + return true + } + return false +} + +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].Name == "" { + return nil, fmt.Errorf("name is required for each user") + } + if req.Users[i].FrontendBaseUrl == "" { + return nil, fmt.Errorf("frontendBaseUrl is required for each user") + } + if !isValidRole(req.Users[i].Role) { + return nil, fmt.Errorf("invalid role for user: %s", req.Users[i].Email) + } + } + + return &req, nil +} + func parseSetApdexScoreRequest(r *http.Request) (*model.ApdexSettings, error) { var req model.ApdexSettings if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/pkg/query-service/auth/auth.go b/pkg/query-service/auth/auth.go index 16eea6a5f3..658c1ff4c4 100644 --- a/pkg/query-service/auth/auth.go +++ b/pkg/query-service/auth/auth.go @@ -61,6 +61,16 @@ func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteRespons 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") + } + if err := validateInviteRequest(req); err != nil { return nil, errors.Wrap(err, "invalid invite request") } @@ -79,6 +89,113 @@ func Invite(ctx context.Context, req *model.InviteRequest) (*model.InviteRespons if apiErr != nil { return nil, errors.Wrap(err, "failed to query admin user from the DB") } + + inv := &model.InvitationObject{ + Name: req.Name, + Email: req.Email, + Token: token, + CreatedAt: time.Now().Unix(), + Role: req.Role, + 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) { + response := &model.BulkInviteResponse{ + Status: "success", + Summary: model.InviteSummary{TotalInvites: len(req.Users)}, + SuccessfulInvites: []model.SuccessfulInvite{}, + FailedInvites: []model.FailedInvite{}, + } + + jwtAdmin, ok := ExtractJwtFromContext(ctx) + if !ok { + return nil, errors.New("failed to extract admin jwt token") + } + + adminUser, err := validateUser(jwtAdmin) + if err != nil { + return nil, errors.Wrap(err, "failed to validate admin jwt token") + } + + au, apiErr := dao.DB().GetUser(ctx, adminUser.Id) + 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 *model.UserPayload) (*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") + } + + if err := validateInviteRequest(req); err != nil { + return nil, errors.Wrap(err, "invalid invite request") + } + inv := &model.InvitationObject{ Name: req.Name, Email: req.Email, diff --git a/pkg/query-service/model/auth.go b/pkg/query-service/model/auth.go index 69525def12..c9f5991472 100644 --- a/pkg/query-service/model/auth.go +++ b/pkg/query-service/model/auth.go @@ -27,6 +27,34 @@ type InvitationResponseObject struct { 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"`