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