satellite/console: add endpoint to invite users to project

This change adds a new endpoint that uses the new project invite flow's
 functionality instead of directly adding users to a project's members.

Issue: https://github.com/storj/storj/issues/5741

Change-Id: I6734f7e95be07086387fb133d6bdfd95e47cf4d9
This commit is contained in:
Wilfred Asomani 2023-06-07 21:13:39 +00:00
parent 782811c634
commit 09a7d23003
8 changed files with 194 additions and 1 deletions

View File

@ -602,6 +602,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Console.AuthTokens,
peer.Mail.Service,
externalAddress,
consoleConfig.SatelliteName,
consoleConfig.Config,
)
if err != nil {

View File

@ -65,6 +65,38 @@ func (p *Projects) GetSalt(w http.ResponseWriter, r *http.Request) {
}
}
// InviteUsers sends invites to a given project(id) to the given users (emails).
func (p *Projects) InviteUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
idParam, ok := mux.Vars(r)["id"]
if !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 data struct {
Emails []string `json:"emails"`
}
err = json.NewDecoder(r.Body).Decode(&data)
if err != nil {
p.serveJSONError(w, http.StatusBadRequest, err)
return
}
_, err = p.service.InviteProjectMembers(ctx, id, data.Emails)
if err != nil {
p.serveJSONError(w, http.StatusInternalServerError, err)
}
}
// GetUserInvitations returns the user's pending project member invitations.
func (p *Projects) GetUserInvitations(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -117,6 +117,7 @@ func TestGraphqlMutation(t *testing.T) {
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,

View File

@ -101,6 +101,7 @@ func TestGraphqlQuery(t *testing.T) {
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
"",
console.Config{
PasswordCost: console.TestPasswordCost,
DefaultProjectLimit: 5,

View File

@ -262,6 +262,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc
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("/{id}/invite", server.withAuth(http.HandlerFunc(projectsController.InviteUsers))).Methods(http.MethodPost)
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)

View File

@ -75,6 +75,7 @@ const (
projInviteInvalidErrMsg = "The invitation has expired or is invalid"
projInviteAlreadyMemberErrMsg = "You are already a member of the project"
projInviteResponseInvalidErrMsg = "Invalid project member invitation response"
projInviteExistsErrMsg = "User has already been invited"
)
var (
@ -141,6 +142,9 @@ var (
// ErrProjectInviteInvalid occurs when a user tries to respond to an invitation that doesn't exist
// or has expired.
ErrProjectInviteInvalid = errs.Class("invalid project invitation")
// ErrProjectInviteExists occurs when a user is invited to a project they've already been invited to.
ErrProjectInviteExists = errs.Class("user already invited to project")
)
// Service is handling accounts related logic.
@ -163,6 +167,7 @@ type Service struct {
mailService *mailservice.Service
satelliteAddress string
satelliteName string
config Config
}
@ -226,7 +231,7 @@ type Payments struct {
}
// NewService returns new instance of Service.
func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets buckets.DB, accounts payments.Accounts, depositWallets payments.DepositWallets, billing billing.TransactionsDB, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, config Config) (*Service, error) {
func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting accounting.ProjectAccounting, projectUsage *accounting.Service, buckets buckets.DB, accounts payments.Accounts, depositWallets payments.DepositWallets, billing billing.TransactionsDB, analytics *analytics.Service, tokens *consoleauth.Service, mailService *mailservice.Service, satelliteAddress string, satelliteName string, config Config) (*Service, error) {
if store == nil {
return nil, errs.New("store can't be nil")
}
@ -271,6 +276,7 @@ func NewService(log *zap.Logger, store DB, restKeys RESTKeys, projectAccounting
tokens: tokens,
mailService: mailService,
satelliteAddress: satelliteAddress,
satelliteName: satelliteName,
config: config,
}, nil
}
@ -3576,3 +3582,99 @@ func (s *Service) RespondToProjectInvitation(ctx context.Context, projectID uuid
return nil
}
// InviteProjectMembers invites users by email to given project.
// Email addresses not belonging to a user are ignored.
// projectID here may be project.PublicID or project.ID.
func (s *Service) InviteProjectMembers(ctx context.Context, projectID uuid.UUID, emails []string) (invites []ProjectInvitation, err error) {
defer mon.Task()(&ctx)(&err)
user, err := s.getUserAndAuditLog(ctx, "invite project members", zap.String("projectID", projectID.String()), zap.Strings("emails", emails))
if err != nil {
return nil, Error.Wrap(err)
}
isMember, err := s.isProjectMember(ctx, user.ID, projectID)
if err != nil {
return nil, Error.Wrap(err)
}
projectID = isMember.project.ID
// collect user querying errors
users := make([]*User, 0)
for _, email := range emails {
invitedUser, err := s.store.Users().GetByEmail(ctx, email)
if err == nil {
_, err = s.isProjectMember(ctx, invitedUser.ID, projectID)
if err != nil && !ErrNoMembership.Has(err) {
return nil, Error.Wrap(err)
} else if err == nil {
return nil, ErrAlreadyMember.New("%s is already a member", email)
}
invite, err := s.store.ProjectInvitations().Get(ctx, projectID, email)
if err != nil && !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
if invite != nil && time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
// delete expired invite
err := s.store.ProjectInvitations().Delete(ctx, projectID, invitedUser.Email)
if err != nil {
s.log.Warn("error deleting project invitation",
zap.Error(err),
zap.String("email", invitedUser.Email),
zap.String("projectID", projectID.String()),
)
}
} else if invite != nil && !time.Now().After(invite.CreatedAt.Add(s.config.ProjectInvitationExpiration)) {
return nil, ErrProjectInviteExists.New(projInviteExistsErrMsg)
}
users = append(users, invitedUser)
} else if !errs.Is(err, sql.ErrNoRows) {
return nil, Error.Wrap(err)
}
}
signIn := fmt.Sprintf("%s/login", s.satelliteAddress)
// add project invites in transaction scope
err = s.store.WithTx(ctx, func(ctx context.Context, tx DBTx) error {
for _, invited := range users {
invite, err := tx.ProjectInvitations().Insert(ctx, &ProjectInvitation{
ProjectID: projectID,
Email: invited.Email,
InviterID: &user.ID,
})
if err != nil {
if dbx.IsConstraintError(err) {
// should not happen, but just in case.
return errs.New("%s is already invited", invited.Email)
}
return err
}
invites = append(invites, *invite)
}
return nil
})
if err != nil {
return nil, Error.Wrap(err)
}
for _, invited := range users {
userName := invited.ShortName
if userName == "" {
userName = invited.FullName
}
s.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: invited.Email, Name: userName}},
&ExistingUserProjectInvitationEmail{
InviterEmail: user.Email,
Region: s.satelliteName,
SignInLink: fmt.Sprintf("%s?email=%s", signIn, invited.Email),
},
)
}
return invites, nil
}

View File

@ -2002,6 +2002,60 @@ func TestProjectInvitations(t *testing.T) {
return setInviteDate(ctx, invite, createdAt)
}
t.Run("invite users", func(t *testing.T) {
user, ctx := getUserAndCtx(t)
user2, ctx2 := getUserAndCtx(t)
user3, ctx3 := getUserAndCtx(t)
project, err := sat.AddProject(ctx, user.ID, "Test Project")
require.NoError(t, err)
invites, err := service.InviteProjectMembers(ctx, project.ID, []string{user2.Email})
require.NoError(t, err)
require.Len(t, invites, 1)
invites, err = service.GetUserProjectInvitations(ctx2)
require.NoError(t, err)
require.Len(t, invites, 1)
// adding in a non-existent user should not fail the invitation.
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email, "notauser@mail.com"})
require.NoError(t, err)
require.Len(t, invites, 1)
invites, err = service.GetUserProjectInvitations(ctx3)
require.NoError(t, err)
require.Len(t, invites, 1)
invite := invites[0]
// inviting the same user again should fail if existing invite hasn't expired.
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email})
require.Error(t, err)
// expire the invitation.
setInviteDate(ctx, &invite, time.Now().Add(-sat.Config.Console.ProjectInvitationExpiration))
// inviting the same user again should succeed because the existing invite has expired.
invites, err = service.InviteProjectMembers(ctx, project.ID, []string{user3.Email})
require.NoError(t, err)
require.Len(t, invites, 1)
// prevent unauthorized users from inviting others (user2 is not a member of the project yet).
_, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"})
require.Error(t, err)
require.True(t, console.ErrNoMembership.Has(err))
require.NoError(t, service.RespondToProjectInvitation(ctx2, project.ID, console.ProjectInvitationAccept))
// now that user2 is a member, they can invite others.
_, err = service.InviteProjectMembers(ctx2, project.ID, []string{"other@mail.com"})
require.NoError(t, err)
// inviting a project member should fail.
_, err = service.InviteProjectMembers(ctx, project.ID, []string{user2.Email})
require.Error(t, err)
})
t.Run("get invitation", func(t *testing.T) {
user, ctx := getUserAndCtx(t)

View File

@ -95,6 +95,7 @@ func TestSignupCouponCodes(t *testing.T) {
}, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}),
nil,
"",
"",
console.Config{PasswordCost: console.TestPasswordCost, DefaultProjectLimit: 5},
)