From 6359c537c0d174e152388159ffc66c849bdace68 Mon Sep 17 00:00:00 2001 From: Jeremy Wharton Date: Thu, 1 Jun 2023 02:27:09 -0500 Subject: [PATCH] satellite/console: add API methods for responding to project invites API endpoints and associated methods have been implemented to allow users to accept or decline their pending project member invitations through the satellite frontend. References #5855 Change-Id: Ic23721c64a65e741dc1015838e617fd1af5c8ca4 --- .../console/consoleweb/consoleapi/projects.go | 101 +++++++++++ satellite/console/consoleweb/server.go | 29 ++- satellite/console/service.go | 166 +++++++++++++++--- satellite/console/service_test.go | 123 +++++++++++++ scripts/testdata/satellite-config.yaml.lock | 3 + 5 files changed, 380 insertions(+), 42 deletions(-) diff --git a/satellite/console/consoleweb/consoleapi/projects.go b/satellite/console/consoleweb/consoleapi/projects.go index 953ba4533..3c4bda306 100644 --- a/satellite/console/consoleweb/consoleapi/projects.go +++ b/satellite/console/consoleweb/consoleapi/projects.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "net/http" + "time" "github.com/gorilla/mux" "github.com/zeebo/errs" @@ -64,6 +65,106 @@ func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) { } } +// GetUserInvitations returns the user's pending project member invitations. +func (p *Projects) GetUserInvitations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + invites, err := p.service.GetUserProjectInvitations(ctx) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + type jsonInvite struct { + ProjectID uuid.UUID `json:"projectID"` + ProjectName string `json:"projectName"` + ProjectDescription string `json:"projectDescription"` + InviterEmail string `json:"inviterEmail"` + CreatedAt time.Time `json:"createdAt"` + } + + response := make([]jsonInvite, 0) + + for _, invite := range invites { + proj, err := p.service.GetProjectNoAuth(ctx, invite.ProjectID) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + respInvite := jsonInvite{ + ProjectID: proj.PublicID, + ProjectName: proj.Name, + ProjectDescription: proj.Description, + CreatedAt: invite.CreatedAt, + } + + if invite.InviterID != nil { + inviter, err := p.service.GetUser(ctx, *invite.InviterID) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + return + } + respInvite.InviterEmail = inviter.Email + } + + response = append(response, respInvite) + } + + err = json.NewEncoder(w).Encode(response) + if err != nil { + p.serveJSONError(w, http.StatusInternalServerError, err) + } +} + +// RespondToInvitation handles accepting or declining a user's project member invitation. +func (p *Projects) RespondToInvitation(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + var ok bool + var idParam string + + if idParam, ok = mux.Vars(r)["id"]; !ok { + p.serveJSONError(w, http.StatusBadRequest, errs.New("missing project id route param")) + return + } + + id, err := uuid.FromString(idParam) + if err != nil { + p.serveJSONError(w, http.StatusBadRequest, err) + } + + var payload struct { + Response console.ProjectInvitationResponse `json:"response"` + } + + err = json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + p.serveJSONError(w, http.StatusBadRequest, err) + return + } + + err = p.service.RespondToProjectInvitation(ctx, id, payload.Response) + if err != nil { + status := http.StatusInternalServerError + switch { + case console.ErrAlreadyMember.Has(err): + status = http.StatusConflict + case console.ErrProjectInviteInvalid.Has(err): + status = http.StatusNotFound + case console.ErrValidation.Has(err): + status = http.StatusBadRequest + } + p.serveJSONError(w, status, err) + } +} + // serveJSONError writes JSON error to response output stream. func (p *Projects) serveJSONError(w http.ResponseWriter, status int, err error) { web.ServeJSONError(p.log, w, status, err) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 9df82f1ff..b7db69698 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -251,32 +251,23 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc consoleapi.NewUserManagement(logger, mon, server.service, router, &apiAuth{&server}) } - projectsController := consoleapi.NewProjects(logger, service) - router.Handle( - "/api/v0/projects/{id}/salt", - server.withAuth(http.HandlerFunc(projectsController.GetSalt)), - ).Methods(http.MethodGet) - router.HandleFunc("/api/v0/config", server.frontendConfigHandler) + router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler))) + router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler) router.HandleFunc("/robots.txt", server.seoHandler) - router.Handle("/api/v0/graphql", server.withAuth(http.HandlerFunc(server.graphqlHandler))) + projectsController := consoleapi.NewProjects(logger, service) + projectsRouter := router.PathPrefix("/api/v0/projects").Subrouter() + projectsRouter.Handle("/{id}/salt", server.withAuth(http.HandlerFunc(projectsController.GetSalt))).Methods(http.MethodGet) + projectsRouter.Handle("/invitations", server.withAuth(http.HandlerFunc(projectsController.GetUserInvitations))).Methods(http.MethodGet) + projectsRouter.Handle("/invitations/{id}/respond", server.withAuth(http.HandlerFunc(projectsController.RespondToInvitation))).Methods(http.MethodPost) usageLimitsController := consoleapi.NewUsageLimits(logger, service) - router.Handle( - "/api/v0/projects/{id}/usage-limits", - server.withAuth(http.HandlerFunc(usageLimitsController.ProjectUsageLimits)), - ).Methods(http.MethodGet) - router.Handle( - "/api/v0/projects/usage-limits", - server.withAuth(http.HandlerFunc(usageLimitsController.TotalUsageLimits)), - ).Methods(http.MethodGet) - router.Handle( - "/api/v0/projects/{id}/daily-usage", - server.withAuth(http.HandlerFunc(usageLimitsController.DailyUsage)), - ).Methods(http.MethodGet) + projectsRouter.Handle("/{id}/usage-limits", server.withAuth(http.HandlerFunc(usageLimitsController.ProjectUsageLimits))).Methods(http.MethodGet) + projectsRouter.Handle("/usage-limits", server.withAuth(http.HandlerFunc(usageLimitsController.TotalUsageLimits))).Methods(http.MethodGet) + projectsRouter.Handle("/{id}/daily-usage", server.withAuth(http.HandlerFunc(usageLimitsController.DailyUsage))).Methods(http.MethodGet) authController := consoleapi.NewAuth(logger, service, accountFreezeService, mailService, server.cookieAuth, server.analytics, config.SatelliteName, server.config.ExternalAddress, config.LetUsKnowURL, config.TermsAndConditionsURL, config.ContactInfoURL, config.GeneralRequestURL) authRouter := router.PathPrefix("/api/v0/auth").Subrouter() diff --git a/satellite/console/service.go b/satellite/console/service.go index c1d4c9ae2..e009e2c88 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -8,7 +8,6 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "math" "net/http" @@ -70,10 +69,13 @@ const ( apiKeyWithNameDoesntExistErrMsg = "An API Key with this name doesn't exist in this project." teamMemberDoesNotExistErrMsg = `There is no account on this Satellite for the user(s) you have entered. Please add team members with active accounts` - activationTokenExpiredErrMsg = "This activation token has expired, please request another one" - usedRegTokenErrMsg = "This registration token has already been used" - projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!" - projNameErrMsg = "The new project must have a name you haven't used before!" + activationTokenExpiredErrMsg = "This activation token has expired, please request another one" + usedRegTokenErrMsg = "This registration token has already been used" + projLimitErrMsg = "Sorry, project creation is limited for your account. Please contact support!" + projNameErrMsg = "The new project must have a name you haven't used before!" + projInviteInvalidErrMsg = "The invitation has expired or is invalid" + projInviteAlreadyMemberErrMsg = "You are already a member of the project" + projInviteResponseInvalidErrMsg = "Invalid project member invitation response" ) var ( @@ -133,6 +135,13 @@ var ( // ErrAlreadyHasPackage is error that occurs when a user tries to update package, but already has one. ErrAlreadyHasPackage = errs.Class("user already has package") + + // ErrAlreadyMember occurs when a user tries to reject an invitation to a project they're already a member of. + ErrAlreadyMember = errs.Class("already a member") + + // ErrProjectInviteInvalid occurs when a user tries to respond to an invitation that doesn't exist + // or has expired. + ErrProjectInviteInvalid = errs.Class("invalid project invitation") ) // Service is handling accounts related logic. @@ -179,6 +188,7 @@ type Config struct { AsOfSystemTimeDuration time.Duration `help:"default duration for AS OF SYSTEM TIME" devDefault:"-5m" releaseDefault:"-5m" testDefault:"0"` LoginAttemptsWithoutPenalty int `help:"number of times user can try to login without penalty" default:"3"` FailedLoginPenalty float64 `help:"incremental duration of penalty for failed login attempts in minutes" default:"2.0"` + ProjectInvitationExpiration time.Duration `help:"duration that project member invitations are valid for" default:"168h"` UsageLimits UsageLimitsConfig Captcha CaptchaConfig Session SessionConfig @@ -1464,8 +1474,7 @@ func (s *Service) DeleteAccount(ctx context.Context, password string) (err error return nil } -// GetProject is a method for querying project by id. -// projectID here may be project.PublicID or project.ID. +// GetProject is a method for querying project by internal or public ID. func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Project, err error) { defer mon.Task()(&ctx)(&err) user, err := s.getUserAndAuditLog(ctx, "get project", zap.String("projectID", projectID.String())) @@ -1483,6 +1492,27 @@ func (s *Service) GetProject(ctx context.Context, projectID uuid.UUID) (p *Proje return } +// GetProjectNoAuth is a method for querying project by ID or public ID. +// This is for internal use only as it ignores whether a user is authorized to perform this action. +// If authorization checking is required, use GetProject. +func (s *Service) GetProjectNoAuth(ctx context.Context, projectID uuid.UUID) (p *Project, err error) { + defer mon.Task()(&ctx)(&err) + + p, err = s.store.Projects().GetByPublicID(ctx, projectID) + if err != nil { + if errs.Is(err, sql.ErrNoRows) { + p, err = s.store.Projects().Get(ctx, projectID) + if err != nil { + return nil, Error.Wrap(err) + } + } else { + return nil, Error.Wrap(err) + } + } + + return p, nil +} + // GetSalt is a method for querying project salt by id. // id may be project.ID or project.PublicID. func (s *Service) GetSalt(ctx context.Context, projectID uuid.UUID) (salt []byte, err error) { @@ -2938,16 +2968,9 @@ type isProjectMember struct { func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (isOwner bool, project *Project, err error) { defer mon.Task()(&ctx)(&err) - project, err = s.store.Projects().GetByPublicID(ctx, projectID) + project, err = s.GetProjectNoAuth(ctx, projectID) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - project, err = s.store.Projects().Get(ctx, projectID) - if err != nil { - return false, nil, Error.Wrap(err) - } - } else { - return false, nil, Error.Wrap(err) - } + return false, nil, err } if project.OwnerID != userID { @@ -2961,14 +2984,10 @@ func (s *Service) isProjectOwner(ctx context.Context, userID uuid.UUID, projectI // projectID can be either private ID or public ID (project.ID/project.PublicID). func (s *Service) isProjectMember(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) (_ isProjectMember, err error) { defer mon.Task()(&ctx)(&err) - var project *Project - project, err = s.store.Projects().GetByPublicID(ctx, projectID) + + project, err := s.GetProjectNoAuth(ctx, projectID) if err != nil { - tempError := err - project, err = s.store.Projects().Get(ctx, projectID) - if err != nil { - return isProjectMember{}, Error.Wrap(errs.Combine(tempError, err)) - } + return isProjectMember{}, err } memberships, err := s.store.ProjectMembers().GetByMemberID(ctx, userID) @@ -3393,3 +3412,104 @@ func (s *Service) SetUserSettings(ctx context.Context, request UpsertUserSetting return settings, nil } + +// GetUserProjectInvitations returns a user's pending project member invitations. +func (s *Service) GetUserProjectInvitations(ctx context.Context) (_ []ProjectInvitation, err error) { + defer mon.Task()(&ctx)(&err) + + user, err := s.getUserAndAuditLog(ctx, "get project member invitations") + if err != nil { + return nil, Error.Wrap(err) + } + + invites, err := s.store.ProjectInvitations().GetByEmail(ctx, user.Email) + if err != nil { + return nil, Error.Wrap(err) + } + + return invites, nil +} + +// ProjectInvitationResponse represents a response to a project member invitation. +type ProjectInvitationResponse int + +const ( + // ProjectInvitationDecline represents rejection of a project member invitation. + ProjectInvitationDecline ProjectInvitationResponse = iota + // ProjectInvitationAccept represents acceptance of a project member invitation. + ProjectInvitationAccept +) + +// RespondToProjectInvitation handles accepting or declining a user's project member invitation. +// The given project ID may be the internal or public ID. +func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid.UUID, response ProjectInvitationResponse) (err error) { + defer mon.Task()(&ctx)(&err) + + user, err := s.getUserAndAuditLog(ctx, "project member invitation response", + zap.String("projectID", projectID.String()), + zap.Any("response", response), + ) + if err != nil { + return Error.Wrap(err) + } + + if response != ProjectInvitationAccept && response != ProjectInvitationDecline { + return ErrValidation.New(projInviteResponseInvalidErrMsg) + } + + proj, err := s.GetProjectNoAuth(ctx, projectID) + if err != nil { + return Error.Wrap(err) + } + projectID = proj.ID + + // log deletion errors that don't affect the outcome + deleteWithLog := func() { + err := s.store.ProjectInvitations().Delete(ctx, projectID, user.Email) + if err != nil { + s.log.Warn("error deleting project invitation", + zap.Error(err), + zap.String("email", user.Email), + zap.String("projectID", projectID.String()), + ) + } + } + + _, err = s.isProjectMember(ctx, user.ID, projectID) + if err == nil { + deleteWithLog() + if response == ProjectInvitationDecline { + return ErrAlreadyMember.New(projInviteAlreadyMemberErrMsg) + } + return nil + } + + invite, err := s.store.ProjectInvitations().Get(ctx, projectID, user.Email) + if err != nil { + if !errs.Is(err, sql.ErrNoRows) { + return Error.Wrap(err) + } + if response == ProjectInvitationDecline { + return nil + } + return ErrProjectInviteInvalid.New(projInviteInvalidErrMsg) + } + + if time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) { + deleteWithLog() + return ErrProjectInviteInvalid.New(projInviteInvalidErrMsg) + } + + if response == ProjectInvitationDecline { + return Error.Wrap(s.store.ProjectInvitations().Delete(ctx, projectID, user.Email)) + } + + _, err = s.store.ProjectMembers().Insert(ctx, user.ID, projectID) + if err != nil { + return Error.Wrap(err) + } + + deleteWithLog() + + return nil +} diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index 0d87cceb4..537eb7de9 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -11,6 +11,7 @@ import ( "fmt" "math/rand" "sort" + "strings" "testing" "time" @@ -1887,3 +1888,125 @@ func TestServiceGenMethods(t *testing.T) { } }) } + +func TestProjectInvitations(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + service := sat.API.Console.Service + + addUser := func(t *testing.T, ctx context.Context) *console.User { + user, err := sat.AddUser(ctx, console.CreateUser{ + FullName: "Test User", + Email: fmt.Sprintf("%s@mail.test", testrand.RandAlphaNumeric(16)), + }, 1) + require.NoError(t, err) + return user + } + + getUserAndCtx := func(t *testing.T) (*console.User, context.Context) { + ctx := testcontext.New(t) + user := addUser(t, ctx) + userCtx, err := sat.UserContext(ctx, user.ID) + require.NoError(t, err) + return user, userCtx + } + + addProject := func(t *testing.T, ctx context.Context) *console.Project { + owner := addUser(t, ctx) + project, err := sat.AddProject(ctx, owner.ID, "Test Project") + require.NoError(t, err) + return project + } + + addInvite := func(t *testing.T, ctx context.Context, project *console.Project, email string, createdAt time.Time) *console.ProjectInvitation { + invite, err := sat.DB.Console().ProjectInvitations().Insert(ctx, &console.ProjectInvitation{ + ProjectID: project.ID, + Email: email, + InviterID: &project.OwnerID, + }) + require.NoError(t, err) + + result, err := sat.DB.Testing().RawDB().ExecContext(ctx, + "UPDATE project_invitations SET created_at = $1 WHERE project_id = $2 AND email = $3", + createdAt, invite.ProjectID, strings.ToUpper(invite.Email), + ) + require.NoError(t, err) + + count, err := result.RowsAffected() + require.NoError(t, err) + require.EqualValues(t, 1, count) + + return invite + } + + t.Run("get invitation", func(t *testing.T) { + user, ctx := getUserAndCtx(t) + + invites, err := service.GetUserProjectInvitations(ctx) + require.NoError(t, err) + require.Empty(t, invites) + + invite := addInvite(t, ctx, addProject(t, ctx), user.Email, time.Now()) + invites, err = service.GetUserProjectInvitations(ctx) + require.NoError(t, err) + require.Len(t, invites, 1) + require.Equal(t, invite.ProjectID, invites[0].ProjectID) + require.Equal(t, invite.Email, invites[0].Email) + require.Equal(t, invite.InviterID, invites[0].InviterID) + require.WithinDuration(t, invite.CreatedAt, invites[0].CreatedAt, time.Second) + }) + + t.Run("accept invitation", func(t *testing.T) { + user, ctx := getUserAndCtx(t) + proj := addProject(t, ctx) + + addInvite(t, ctx, proj, user.Email, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration)) + err := service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept) + require.True(t, console.ErrProjectInviteInvalid.Has(err)) + + addInvite(t, ctx, proj, user.Email, time.Now()) + require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)) + + invites, err := service.GetUserProjectInvitations(ctx) + require.NoError(t, err) + require.Empty(t, invites) + + memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID) + require.NoError(t, err) + require.Len(t, memberships, 1) + require.Equal(t, proj.ID, memberships[0].ProjectID) + + // Ensure that accepting an invitation for a project you are already a member of doesn't return an error. + // This is because the outcome of the operation is the same as if you weren't a member. + require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept)) + // Ensure that an error is returned if you're a member of a project whose invitation you decline. + err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline) + require.True(t, console.ErrAlreadyMember.Has(err)) + }) + + t.Run("reject invitation", func(t *testing.T) { + user, ctx := getUserAndCtx(t) + proj := addProject(t, ctx) + + addInvite(t, ctx, proj, user.Email, time.Now()) + require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline)) + + invites, err := service.GetUserProjectInvitations(ctx) + require.NoError(t, err) + require.Empty(t, invites) + + memberships, err := sat.DB.Console().ProjectMembers().GetByMemberID(ctx, user.ID) + require.NoError(t, err) + require.Empty(t, memberships) + + // Ensure that declining an invitation for a project you are not a member of doesn't return an error. + // This is because the outcome of the operation is the same as if you were a member. + require.NoError(t, service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationDecline)) + // Ensure that an error is returned if you try to accept an invitation that you have already declined or doesn't exist. + err = service.RespondToProjectInvitation(ctx, proj.ID, console.ProjectInvitationAccept) + require.True(t, console.ErrProjectInviteInvalid.Has(err)) + }) + }) +} diff --git a/scripts/testdata/satellite-config.yaml.lock b/scripts/testdata/satellite-config.yaml.lock index 89babc437..1c63288c9 100755 --- a/scripts/testdata/satellite-config.yaml.lock +++ b/scripts/testdata/satellite-config.yaml.lock @@ -325,6 +325,9 @@ compensation.withheld-percents: 75,75,75,50,50,50,25,25,25,0,0,0,0,0,0 # whether to allow purchasing pricing packages # console.pricing-packages-enabled: false +# duration that project member invitations are valid for +# console.project-invitation-expiration: 168h0m0s + # url link to project limit increase request page # console.project-limits-increase-request-url: https://supportdcs.storj.io/hc/en-us/requests/new?ticket_form_id=360000683212