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
This commit is contained in:
Jeremy Wharton 2023-06-01 02:27:09 -05:00 committed by Storj Robot
parent b64be2338d
commit 6359c537c0
5 changed files with 380 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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