diff --git a/api/services/auth.go b/api/services/auth.go index ed42934ab1a..f32ea800a64 100644 --- a/api/services/auth.go +++ b/api/services/auth.go @@ -309,10 +309,8 @@ func (s *service) AuthLocalUser(ctx context.Context, req *requests.AuthLocalUser tenantID := "" role := "" - // Populate the tenant and role when the user is associated with a namespace. If the member status is pending, we - // ignore the namespace. if ns, _ := s.store.NamespaceGetPreferred(ctx, user.ID); ns != nil && ns.TenantID != "" { - if m, _ := ns.FindMember(user.ID); m.Status != models.MemberStatusPending { + if m, _ := ns.FindMember(user.ID); m != nil { tenantID = ns.TenantID role = m.Role.String() } @@ -393,10 +391,8 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT return nil, NewErrNamespaceMemberNotFound(user.ID, nil) } - if member.Status != models.MemberStatusPending { - tenantID = namespace.TenantID - role = member.Role.String() - } + tenantID = namespace.TenantID + role = member.Role.String() default: namespace, err := s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) if err != nil { @@ -408,10 +404,6 @@ func (s *service) CreateUserToken(ctx context.Context, req *requests.CreateUserT return nil, NewErrNamespaceMemberNotFound(user.ID, nil) } - if member.Status == models.MemberStatusPending { - return nil, NewErrNamespaceMemberNotFound(user.ID, nil) - } - tenantID = namespace.TenantID role = member.Role.String() diff --git a/api/services/auth_test.go b/api/services/auth_test.go index 90ad3112183..8f818ec4f66 100644 --- a/api/services/auth_test.go +++ b/api/services/auth_test.go @@ -1890,134 +1890,6 @@ func TestService_AuthLocalUser(t *testing.T) { err: nil, }, }, - { - description: "succeeds to authenticate with a namespace (and member status 'pending')", - sourceIP: "127.0.0.1", - req: &requests.AuthLocalUser{ - Identifier: "john_doe", - Password: "secret", - }, - requiredMocks: func() { - user := &models.User{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal, - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "00000000-0000-4000-0000-000000000000", - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - }, - } - updatedUser := &models.User{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal, - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "", - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - }, - } - - mock. - On("SystemGet", ctx). - Return( - &models.System{ - Authentication: &models.SystemAuthentication{ - Local: &models.SystemAuthenticationLocal{ - Enabled: true, - }, - }, - }, - nil, - ). - Once() - mock. - On("UserResolve", ctx, store.UserUsernameResolver, "john_doe"). - Return(user, nil). - Once() - cacheMock. - On("HasAccountLockout", ctx, "127.0.0.1", "65fdd16b5f62f93184ec8a39"). - Return(int64(0), 0, nil). - Once() - hashMock. - On("CompareWith", "secret", "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi"). - Return(true). - Once() - cacheMock. - On("ResetLoginAttempts", ctx, "127.0.0.1", "65fdd16b5f62f93184ec8a39"). - Return(nil). - Once() - - ns := &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Members: []models.Member{ - { - ID: "65fdd16b5f62f93184ec8a39", - Role: "owner", - Status: models.MemberStatusPending, - }, - }, - } - - mock. - On("NamespaceGetPreferred", ctx, "65fdd16b5f62f93184ec8a39"). - Return(ns, nil). - Once() - - clockMock := new(clockmock.Clock) - clock.DefaultBackend = clockMock - clockMock.On("Now").Return(now) - - cacheMock. - On("Set", ctx, "token_65fdd16b5f62f93184ec8a39", testifymock.Anything, time.Hour*72). - Return(nil). - Once() - - mock. - On("UserUpdate", ctx, updatedUser). - Return(nil). - Once() - }, - expected: Expected{ - res: &models.UserAuthResponse{ - ID: "65fdd16b5f62f93184ec8a39", - Origin: models.UserOriginLocal.String(), - AuthMethods: []models.UserAuthMethod{models.UserAuthMethodLocal}, - Name: "john doe", - User: "john_doe", - Email: "john.doe@test.com", - Tenant: "", - Role: "", - Token: "must ignore", - }, - lockout: 0, - mfaToken: "", - err: nil, - }, - }, { description: "succeeds to authenticate with a namespace (and empty preferred namespace)", sourceIP: "127.0.0.1", @@ -2391,57 +2263,6 @@ func TestCreateUserToken(t *testing.T) { err: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), }, }, - { - description: "[with-tenant] fails when user membership is pending", - req: &requests.CreateUserToken{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000"}, - requiredMocks: func(ctx context.Context) { - storeMock. - On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). - Return( - &models.User{ - ID: "000000000000000000000000", - Status: models.UserStatusConfirmed, - LastLogin: now, - MFA: models.UserMFA{ - Enabled: false, - }, - UserData: models.UserData{ - Username: "john_doe", - Email: "john.doe@test.com", - Name: "john doe", - }, - Password: models.UserPassword{ - Hash: "$2a$10$V/6N1wsjheBVvWosPfv02uf4WAOb9lmp8YWQCIa2UYuFV4OJby7Yi", - }, - Preferences: models.UserPreferences{ - PreferredNamespace: "", - }, - }, - nil, - ). - Once() - storeMock. - On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: "administrator", - Status: models.MemberStatusPending, - }, - }, - }, - nil, - ). - Once() - }, - expected: Expected{ - res: nil, - err: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), - }, - }, { description: "[with-tenant] succeeds", req: &requests.CreateUserToken{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000"}, @@ -2496,9 +2317,8 @@ func TestCreateUserToken(t *testing.T) { TenantID: "00000000-0000-4000-0000-000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: "owner", - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: "owner", }, }, }, @@ -2566,9 +2386,8 @@ func TestCreateUserToken(t *testing.T) { TenantID: "00000000-0000-4000-0000-000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: "owner", - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: "owner", }, }, }, diff --git a/api/services/member.go b/api/services/member.go index ba721507c20..1f6285c07a7 100644 --- a/api/services/member.go +++ b/api/services/member.go @@ -23,10 +23,10 @@ type MemberService interface { // AddNamespaceMember adds a member to a namespace. // - // In cloud environments, the member is assigned a [MemberStatusPending] status until they accept the invite via + // In cloud environments, a membership invitation is created with pending status until they accept the invite via // an invitation email. If the target user does not exist, the email will redirect them to the registration page, - // and the invite can be accepted after finishing. In community and enterprise environments, the status is set to - // [MemberStatusAccepted] without sending an email. + // and the invite can be accepted after finishing. In community and enterprise environments, the member is added + // directly to the namespace without sending an email. // // The role assigned to the new member must not grant more authority than the user adding them (e.g., // an administrator cannot add a member with a higher role such as an owner). Owners cannot be created. @@ -35,11 +35,15 @@ type MemberService interface { AddNamespaceMember(ctx context.Context, req *requests.NamespaceAddMember) (*models.Namespace, error) // UpdateNamespaceMember updates a member with the specified ID in the specified namespace. The member's role cannot - // have more authority than the user who is updating the member; owners cannot be created. It returns an error, if any. + // have more authority than the user who is updating the member; owners cannot be created. + // + // It returns an error, if any. UpdateNamespaceMember(ctx context.Context, req *requests.NamespaceUpdateMember) error // RemoveNamespaceMember removes a specified member from a namespace. The action must be performed by a user with higher - // authority than the target member. Owners cannot be removed. Returns the updated namespace and an error, if any. + // authority than the target member. Owners cannot be removed. + // + // Returns the updated namespace and an error, if any. RemoveNamespaceMember(ctx context.Context, req *requests.NamespaceRemoveMember) (*models.Namespace, error) // LeaveNamespace allows an authenticated user to remove themselves from a namespace. Owners cannot leave a namespace. @@ -85,54 +89,81 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac } } - // In cloud instances, if a member exists and their status is pending and the expiration date is reached, - // we resend the invite instead of adding the member. - // In community and enterprise instances, a "duplicate" error is always returned, - // since the member will never be in a pending status. - // Otherwise, add the member "from scratch" - if m, ok := namespace.FindMember(passiveUser.ID); ok { - now := clock.Now() - - if !envs.IsCloud() || (m.Status != models.MemberStatusPending || !m.ExpiresAt.Before(now)) { - return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) - } + if _, ok := namespace.FindMember(passiveUser.ID); ok { + return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) + } - if err := s.store.WithTransaction(ctx, s.resendMemberInvite(m, req)); err != nil { - return nil, err - } + var callback store.TransactionCb + if !envs.IsCloud() { + callback = s.addMember(namespace, passiveUser.ID, req) } else { - if err := s.store.WithTransaction(ctx, s.addMember(passiveUser.ID, req)); err != nil { + invitation, err := s.store.MembershipInvitationResolve(ctx, req.TenantID, passiveUser.ID) + if err != nil && !errors.Is(err, store.ErrNoDocuments) { return nil, err } + + switch { + case invitation == nil, !invitation.IsPending(): + callback = s.addMember(namespace, passiveUser.ID, req) + case invitation.IsExpired(): + callback = s.resendMembershipInvite(invitation, req) + default: + return nil, NewErrNamespaceMemberDuplicated(passiveUser.ID, nil) + } } - return s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) + if err := s.store.WithTransaction(ctx, callback); err != nil { + return nil, err + } + + n, err := s.store.NamespaceResolve(ctx, store.NamespaceTenantIDResolver, req.TenantID) + if err != nil { + return nil, err + } + + return n, nil } -// addMember returns a transaction callback that adds a member and sends an invite if the instance is cloud. -func (s *service) addMember(memberID string, req *requests.NamespaceAddMember) store.TransactionCb { +// addMember returns a transaction callback that adds a member to a namespace. +// +// In all environments, it creates a membership_invitation record for audit purposes: +// - Cloud: Creates pending invitation with expiration and sends email +// - Community/Enterprise: Creates accepted invitation and adds member directly to namespace +func (s *service) addMember(namespace *models.Namespace, userID string, req *requests.NamespaceAddMember) store.TransactionCb { return func(ctx context.Context) error { - member := &models.Member{ - ID: memberID, - AddedAt: clock.Now(), - Role: req.MemberRole, + now := clock.Now() + + invitation := &models.MembershipInvitation{ + TenantID: req.TenantID, + UserID: userID, + InvitedBy: namespace.Owner, + Role: req.MemberRole, + CreatedAt: now, + UpdatedAt: now, + StatusUpdatedAt: now, + Invitations: 1, } - // In cloud instances, the member must accept the invite before enter in the namespace. if envs.IsCloud() { - member.Status = models.MemberStatusPending - member.ExpiresAt = member.AddedAt.Add(7 * (24 * time.Hour)) - } else { - member.Status = models.MemberStatusAccepted - member.ExpiresAt = time.Time{} - } + expiresAt := now.Add(7 * (24 * time.Hour)) + invitation.Status = models.MembershipInvitationStatusPending + invitation.ExpiresAt = &expiresAt + if err := s.store.MembershipInvitationCreate(ctx, invitation); err != nil { + return err + } - if err := s.store.NamespaceCreateMembership(ctx, req.TenantID, member); err != nil { - return err - } + if err := s.client.InviteMember(ctx, req.TenantID, userID, req.FowardedHost); err != nil { + return err + } + } else { + invitation.Status = models.MembershipInvitationStatusAccepted + invitation.ExpiresAt = nil + if err := s.store.MembershipInvitationCreate(ctx, invitation); err != nil { + return err + } - if envs.IsCloud() { - if err := s.client.InviteMember(ctx, req.TenantID, member.ID, req.FowardedHost); err != nil { + member := &models.Member{ID: userID, AddedAt: now, Role: req.MemberRole} + if err := s.store.NamespaceCreateMembership(ctx, req.TenantID, member); err != nil { return err } } @@ -141,18 +172,27 @@ func (s *service) addMember(memberID string, req *requests.NamespaceAddMember) s } } -// resendMemberInvite returns a transaction callback that resends an invitation to the member with the -// specified ID. -func (s *service) resendMemberInvite(member *models.Member, req *requests.NamespaceAddMember) store.TransactionCb { +// resendMembershipInvite returns a transaction callback that resends a membership invitation. +// +// This function updates an existing invitation to pending status, extends the expiration date, +// increments the invitation counter, and sends a new invitation email (cloud only). +func (s *service) resendMembershipInvite(invitation *models.MembershipInvitation, req *requests.NamespaceAddMember) store.TransactionCb { return func(ctx context.Context) error { - member.ExpiresAt = clock.Now().Add(7 * (24 * time.Hour)) - member.Role = req.MemberRole + now := clock.Now() + + expiresAt := now.Add(7 * (24 * time.Hour)) + invitation.Status = models.MembershipInvitationStatusPending + invitation.Role = req.MemberRole + invitation.ExpiresAt = &expiresAt + invitation.UpdatedAt = now + invitation.StatusUpdatedAt = now + invitation.Invitations++ - if err := s.store.NamespaceUpdateMembership(ctx, req.TenantID, member); err != nil { + if err := s.store.MembershipInvitationUpdate(ctx, invitation); err != nil { return err } - return s.client.InviteMember(ctx, req.TenantID, member.ID, req.FowardedHost) + return s.client.InviteMember(ctx, req.TenantID, invitation.UserID, req.FowardedHost) } } diff --git a/api/services/member_test.go b/api/services/member_test.go index 0d97eb65cfe..db7c8f7c9b3 100644 --- a/api/services/member_test.go +++ b/api/services/member_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/mock" ) -func TestAddNamespaceMember(t *testing.T) { +func TestService_AddNamespaceMember(t *testing.T) { type Expected struct { namespace *models.Namespace err error @@ -44,7 +44,7 @@ func TestAddNamespaceMember(t *testing.T) { expected Expected }{ { - description: "fails when the namespace was not found", + description: "[community|enterprise|cloud] fails when the namespace was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -64,7 +64,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member was not found", + description: "[community|enterprise|cloud] fails when the active member was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -93,7 +93,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -125,7 +125,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the passive role's is owner", + description: "[community|enterprise|cloud] fails when the passive role's is owner", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -142,9 +142,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOperator, }, }, }, nil). @@ -163,7 +162,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -180,9 +179,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOperator, }, }, }, nil). @@ -201,7 +199,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when passive member was not found", + description: "[community|enterprise] fails when passive member was not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -218,9 +216,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -247,7 +244,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when the member is duplicated without 'pending' status and expiration date not reached", + description: "[community|enterprise|cloud] fails when the member is duplicated", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -264,14 +261,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, }, }, }, nil). @@ -290,10 +285,6 @@ func TestAddNamespaceMember(t *testing.T) { UserData: models.UserData{Username: "john_doe"}, }, nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("false"). - Once() }, expected: Expected{ namespace: nil, @@ -301,7 +292,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] fails when the member is duplicated without 'pending' status and expiration date not reached", + description: "[cloud] fails when the member has pending invitation not expired", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -318,14 +309,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -348,6 +333,18 @@ func TestAddNamespaceMember(t *testing.T) { On("Get", "SHELLHUB_CLOUD"). Return("true"). Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return( + &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Status: models.MembershipInvitationStatusPending, + ExpiresAt: &[]time.Time{time.Now().Add(14 * (24 * time.Hour))}[0], + }, + nil, + ). + Once() }, expected: Expected{ namespace: nil, @@ -355,7 +352,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] fails when the member is duplicated with 'pending' status and expiration date not reached", + description: "[community|enterprise] fails when cannot add the member", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -372,15 +369,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, - ExpiresAt: time.Now().Add(7 * (24 * time.Hour)), + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -401,16 +391,20 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("true"). + Return("false"). + Once() + storeMock. + On("WithTransaction", ctx, mock.Anything). + Return(errors.New("error")). Once() }, expected: Expected{ namespace: nil, - err: NewErrNamespaceMemberDuplicated("000000000000000000000001", nil), + err: errors.New("error"), }, }, { - description: "[cloud] succeeds to resend the invite", + description: "[community|enterprise] succeeds", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -427,15 +421,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, - ExpiresAt: time.Date(2023, 0o1, 0o1, 12, 0o0, 0o0, 0o0, time.UTC), + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -456,7 +443,7 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("true"). + Return("false"). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -464,26 +451,21 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Name: "namespace", - Owner: "000000000000000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }, + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, - nil, - ). + }, nil). Once() }, expected: Expected{ @@ -493,14 +475,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, }, @@ -508,7 +488,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "[cloud] succeeds to create the member when not found", + description: "[cloud] succeeds", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -525,9 +505,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -541,15 +520,18 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("UserResolve", ctx, store.UserEmailResolver, "john.doe@test.com"). - Return(nil, store.ErrNoDocuments). + Return(&models.User{ + ID: "000000000000000000000001", + UserData: models.UserData{Username: "john_doe"}, + }, nil). Once() envMock. On("Get", "SHELLHUB_CLOUD"). Return("true"). Once() storeMock. - On("UserInvitationsUpsert", ctx, "john.doe@test.com"). - Return("000000000000000000000001", nil). + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -557,26 +539,21 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). - Return( - &models.Namespace{ - TenantID: "00000000-0000-4000-0000-000000000000", - Name: "namespace", - Owner: "000000000000000000000000", - Members: []models.Member{ - { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }, + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, - nil, - ). + }, nil). Once() }, expected: Expected{ @@ -586,14 +563,12 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, { - ID: "000000000000000000000001", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, }, }, @@ -601,7 +576,7 @@ func TestAddNamespaceMember(t *testing.T) { }, }, { - description: "fails when cannot add the member", + description: "[cloud] succeeds to resend the invite", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -618,9 +593,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -641,20 +615,53 @@ func TestAddNamespaceMember(t *testing.T) { Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("false"). + Return("true"). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(&models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000001", + Status: models.MembershipInvitationStatusPending, + ExpiresAt: &[]time.Time{time.Date(2023, 0o1, 0o1, 12, 0o0, 0o0, 0o0, time.UTC)}[0], + }, nil). Once() storeMock. On("WithTransaction", ctx, mock.Anything). - Return(errors.New("error")). + Return(nil). + Once() + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, nil). Once() }, expected: Expected{ - namespace: nil, - err: errors.New("error"), + namespace: &models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + }, + }, + err: nil, }, }, { - description: "succeeds", + description: "[cloud] succeeds to create the user when not found", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", UserID: "000000000000000000000000", @@ -671,9 +678,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -687,14 +693,23 @@ func TestAddNamespaceMember(t *testing.T) { Once() storeMock. On("UserResolve", ctx, store.UserEmailResolver, "john.doe@test.com"). - Return(&models.User{ - ID: "000000000000000000000001", - UserData: models.UserData{Username: "john_doe"}, - }, nil). + Return(nil, store.ErrNoDocuments). Once() envMock. On("Get", "SHELLHUB_CLOUD"). - Return("false"). + Return("true"). + Once() + storeMock. + On("UserInvitationsUpsert", ctx, "john.doe@test.com"). + Return("000000000000000000000001", nil). + Once() + envMock. + On("Get", "SHELLHUB_CLOUD"). + Return("true"). + Once() + storeMock. + On("MembershipInvitationResolve", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001"). + Return(nil, store.ErrNoDocuments). Once() storeMock. On("WithTransaction", ctx, mock.Anything). @@ -708,14 +723,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, nil). @@ -728,14 +737,8 @@ func TestAddNamespaceMember(t *testing.T) { Owner: "000000000000000000000000", Members: []models.Member{ { - ID: "000000000000000000000000", - Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, - }, - { - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, }, }, }, @@ -771,13 +774,15 @@ func TestService_addMember(t *testing.T) { cases := []struct { description string + namespace *models.Namespace memberID string req *requests.NamespaceAddMember requiredMocks func(context.Context) expected error }{ { - description: "fails cannot add the member", + description: "[community|enterprise] fails when cannot create membership invitation", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -792,14 +797,21 @@ func TestService_addMember(t *testing.T) { Return("false"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now, ExpiresAt: time.Time{}}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). Return(errors.New("error")). Once() }, expected: errors.New("error"), }, { - description: "succeeds", + description: "[community|enterprise] fails when cannot create namespace membership", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -814,18 +826,58 @@ func TestService_addMember(t *testing.T) { Return("false"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now, ExpiresAt: time.Time{}}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). Return(nil). Once() + storeMock. + On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, AddedAt: now}). + Return(errors.New("error")). + Once() + }, + expected: errors.New("error"), + }, + { + description: "[community|enterprise] succeeds", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, + memberID: "000000000000000000000000", + req: &requests.NamespaceAddMember{ + FowardedHost: "localhost", + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberEmail: "john.doe@test.com", + MemberRole: authorizer.RoleObserver, + }, + requiredMocks: func(ctx context.Context) { envMock. On("Get", "SHELLHUB_CLOUD"). Return("false"). Once() + storeMock. + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusAccepted && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt == nil + })). + Return(nil). + Once() + storeMock. + On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, AddedAt: now}). + Return(nil). + Once() }, expected: nil, }, { - description: "[cloud] fails cannot add the member", + description: "[cloud] fails when cannot create membership invitation", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -840,7 +892,13 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(errors.New("error")). Once() }, @@ -848,6 +906,7 @@ func TestService_addMember(t *testing.T) { }, { description: "[cloud] fails cannot send the invite", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -862,13 +921,15 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("true"). - Once() clientMock. On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000000", "localhost"). Return(errors.New("error")). @@ -878,6 +939,7 @@ func TestService_addMember(t *testing.T) { }, { description: "[cloud] succeeds", + namespace: &models.Namespace{TenantID: "00000000-0000-4000-0000-000000000000", Owner: "000000000000000000000000"}, memberID: "000000000000000000000000", req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -892,13 +954,15 @@ func TestService_addMember(t *testing.T) { Return("true"). Once() storeMock. - On("NamespaceCreateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000000", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now, ExpiresAt: now.Add(7 * (24 * time.Hour))}). + On("MembershipInvitationCreate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil + })). Return(nil). Once() - envMock. - On("Get", "SHELLHUB_CLOUD"). - Return("true"). - Once() clientMock. On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000000", "localhost"). Return(nil). @@ -915,7 +979,7 @@ func TestService_addMember(t *testing.T) { ctx := context.Background() tc.requiredMocks(ctx) - cb := s.addMember(tc.memberID, tc.req) + cb := s.addMember(tc.namespace, tc.memberID, tc.req) assert.Equal(tt, tc.expected, cb(ctx)) storeMock.AssertExpectations(tt) @@ -924,7 +988,7 @@ func TestService_addMember(t *testing.T) { } } -func TestService_resendMemberInvite(t *testing.T) { +func TestService_resendMembershipInvite(t *testing.T) { envMock = new(envmock.Backend) storeMock := new(storemock.Store) clockMock := new(clockmock.Clock) @@ -937,19 +1001,21 @@ func TestService_resendMemberInvite(t *testing.T) { cases := []struct { description string - member *models.Member + invitation *models.MembershipInvitation req *requests.NamespaceAddMember requiredMocks func(context.Context) expected error }{ { - description: "fails cannot update the member", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] fails when cannot update the invitation", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -958,26 +1024,29 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(errors.New("error")). Once() }, expected: errors.New("error"), }, { - description: "fails when cannot send the invite", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] fails when cannot send the invite", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -986,13 +1055,14 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(nil). Once() clientMock. @@ -1003,13 +1073,15 @@ func TestService_resendMemberInvite(t *testing.T) { expected: errors.New("error"), }, { - description: "succeeds", - member: &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(-1 * (24 * time.Hour)), - Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, + description: "[cloud] succeeds", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "000000000000000000000000", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusPending, + CreatedAt: now.Add(-7 * (24 * time.Hour)), + ExpiresAt: &[]time.Time{now.Add(-1 * (24 * time.Hour))}[0], + Invitations: 1, }, req: &requests.NamespaceAddMember{ FowardedHost: "localhost", @@ -1018,13 +1090,14 @@ func TestService_resendMemberInvite(t *testing.T) { }, requiredMocks: func(ctx context.Context) { storeMock. - On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ - ID: "000000000000000000000000", - AddedAt: now.Add(-7 * (24 * time.Hour)), - ExpiresAt: now.Add(7 * (24 * time.Hour)), - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, - }). + On("MembershipInvitationUpdate", ctx, mock.MatchedBy(func(invitation *models.MembershipInvitation) bool { + return invitation.TenantID == "00000000-0000-4000-0000-000000000000" && + invitation.UserID == "000000000000000000000000" && + invitation.Status == models.MembershipInvitationStatusPending && + invitation.Role == authorizer.RoleObserver && + invitation.ExpiresAt != nil && + invitation.Invitations == 2 + })). Return(nil). Once() clientMock. @@ -1043,7 +1116,7 @@ func TestService_resendMemberInvite(t *testing.T) { ctx := context.Background() tc.requiredMocks(ctx) - cb := s.resendMemberInvite(tc.member, tc.req) + cb := s.resendMembershipInvite(tc.invitation, tc.req) assert.Equal(tt, tc.expected, cb(ctx)) storeMock.AssertExpectations(tt) @@ -1052,9 +1125,12 @@ func TestService_resendMemberInvite(t *testing.T) { } } -func TestUpdateNamespaceMember(t *testing.T) { +func TestService_UpdateNamespaceMember(t *testing.T) { + envMock := new(envmock.Backend) storeMock := new(storemock.Store) + envs.DefaultBackend = envMock + cases := []struct { description string req *requests.NamespaceUpdateMember @@ -1062,7 +1138,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected error }{ { - description: "fails when the namespace was not found", + description: "[community|enterprise|cloud] fails when the namespace was not found", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1078,7 +1154,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound), }, { - description: "fails when the active member was not found", + description: "[community|enterprise|cloud] fails when the active member was not found", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1103,7 +1179,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrUserNotFound("000000000000000000000000", ErrUserNotFound), }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1131,7 +1207,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceMemberNotFound("000000000000000000000000", nil), }, { - description: "fails when the passive member is not on the namespace", + description: "[community|enterprise] fails when the passive member is not on the namespace", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1164,7 +1240,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrNamespaceMemberNotFound("000000000000000000000001", nil), }, { - description: "fails when the passive role's is owner", + description: "[community|enterprise|cloud] fails when the passive role's is owner", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1201,7 +1277,7 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrRoleInvalid(), }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1238,7 +1314,48 @@ func TestUpdateNamespaceMember(t *testing.T) { expected: NewErrRoleInvalid(), }, { - description: "fails when cannot update the member", + description: "[community|enterprise|cloud] fails when cannot update the member", + req: &requests.NamespaceUpdateMember{ + UserID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + MemberID: "000000000000000000000001", + MemberRole: authorizer.RoleAdministrator, + }, + requiredMocks: func(ctx context.Context) { + storeMock. + On("NamespaceResolve", ctx, store.NamespaceTenantIDResolver, "00000000-0000-4000-0000-000000000000"). + Return(&models.Namespace{ + TenantID: "00000000-0000-4000-0000-000000000000", + Name: "namespace", + Owner: "000000000000000000000000", + Members: []models.Member{ + { + ID: "000000000000000000000000", + Role: authorizer.RoleOwner, + }, + { + ID: "000000000000000000000001", + Role: authorizer.RoleAdministrator, + }, + }, + }, nil). + Once() + storeMock. + On("UserResolve", ctx, store.UserIDResolver, "000000000000000000000000"). + Return(&models.User{ + ID: "000000000000000000000000", + UserData: models.UserData{Username: "jane_doe"}, + }, nil). + Once() + storeMock. + On("NamespaceUpdateMembership", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleAdministrator}). + Return(errors.New("error")). + Once() + }, + expected: errors.New("error"), + }, + { + description: "[community|enterprise|cloud] succeeds", req: &requests.NamespaceUpdateMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1290,17 +1407,21 @@ func TestUpdateNamespaceMember(t *testing.T) { assert.Equal(t, tc.expected, err) }) } + storeMock.AssertExpectations(t) } -func TestRemoveNamespaceMember(t *testing.T) { +func TestService_RemoveNamespaceMember(t *testing.T) { type Expected struct { namespace *models.Namespace err error } + envMock := new(envmock.Backend) storeMock := new(storemock.Store) + envs.DefaultBackend = envMock + cases := []struct { description string req *requests.NamespaceRemoveMember @@ -1308,7 +1429,7 @@ func TestRemoveNamespaceMember(t *testing.T) { expected Expected }{ { - description: "fails when the namespace was not found", + description: "[community|enterprise|cloud] fails when the namespace was not found", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1326,7 +1447,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member was not found", + description: "[community|enterprise|cloud] fails when the active member was not found", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1353,7 +1474,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member is not on the namespace", + description: "[community|enterprise|cloud] fails when the active member is not on the namespace", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1383,7 +1504,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the passive member is not on the namespace", + description: "[community|enterprise] fails when the passive member is not on the namespace", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1418,7 +1539,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when the active member's role cannot act over passive member's role", + description: "[community|enterprise|cloud] fails when the active member's role cannot act over passive member's role", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1457,7 +1578,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "fails when cannot remove the member", + description: "[community|enterprise|cloud] fails when cannot remove the member", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", @@ -1500,7 +1621,7 @@ func TestRemoveNamespaceMember(t *testing.T) { }, }, { - description: "succeeds", + description: "[community|enterprise|cloud] succeeds", req: &requests.NamespaceRemoveMember{ UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", diff --git a/api/services/namespace.go b/api/services/namespace.go index cad8bfe2107..a040de29759 100644 --- a/api/services/namespace.go +++ b/api/services/namespace.go @@ -60,7 +60,6 @@ func (s *service) CreateNamespace(ctx context.Context, req *requests.NamespaceCr { ID: user.ID, Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: clock.Now(), }, }, diff --git a/api/services/namespace_test.go b/api/services/namespace_test.go index 8069959bca0..1af67dae757 100644 --- a/api/services/namespace_test.go +++ b/api/services/namespace_test.go @@ -14,6 +14,8 @@ import ( storecache "github.com/shellhub-io/shellhub/pkg/cache" "github.com/shellhub-io/shellhub/pkg/clock" clockmock "github.com/shellhub-io/shellhub/pkg/clock/mocks" + "github.com/shellhub-io/shellhub/pkg/envs" + envmock "github.com/shellhub-io/shellhub/pkg/envs/mocks" "github.com/shellhub-io/shellhub/pkg/models" "github.com/shellhub-io/shellhub/pkg/uuid" uuidmocks "github.com/shellhub-io/shellhub/pkg/uuid/mocks" @@ -344,8 +346,11 @@ func TestGetNamespace(t *testing.T) { } func TestCreateNamespace(t *testing.T) { + envMock := new(envmock.Backend) storeMock := new(storemock.Store) clockMock := new(clockmock.Clock) + + envs.DefaultBackend = envMock clock.DefaultBackend = clockMock now := time.Now() @@ -530,7 +535,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -607,7 +611,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -631,7 +634,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -706,7 +708,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -730,7 +731,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -802,7 +802,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -826,7 +825,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -898,7 +896,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -922,7 +919,6 @@ func TestCreateNamespace(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, diff --git a/api/services/setup.go b/api/services/setup.go index db3b194628c..2e5b0553905 100644 --- a/api/services/setup.go +++ b/api/services/setup.go @@ -81,7 +81,6 @@ func (s *service) Setup(ctx context.Context, req requests.Setup) error { { ID: insertedID, Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: clock.Now(), }, }, diff --git a/api/services/setup_test.go b/api/services/setup_test.go index 27074e2c0d8..7eea9a43660 100644 --- a/api/services/setup_test.go +++ b/api/services/setup_test.go @@ -191,7 +191,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -282,7 +281,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, @@ -350,7 +348,6 @@ func TestSetup(t *testing.T) { { ID: "000000000000000000000000", Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, AddedAt: now, }, }, diff --git a/api/store/membership-invitations.go b/api/store/membership-invitations.go new file mode 100644 index 00000000000..1f093773b9c --- /dev/null +++ b/api/store/membership-invitations.go @@ -0,0 +1,19 @@ +package store + +import ( + "context" + + "github.com/shellhub-io/shellhub/pkg/models" +) + +type MembershipInvitationsStore interface { + // MembershipInvitationCreate creates a new membership invitation. + MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error + + // MembershipInvitationResolve retrieves the most recent membership invitation for the specified tenant and user. + // It returns the invitation or an error, if any. + MembershipInvitationResolve(ctx context.Context, tenantID, userID string) (*models.MembershipInvitation, error) + + // MembershipInvitationUpdate updates an existing membership invitation. + MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error +} diff --git a/api/store/mocks/store.go b/api/store/mocks/store.go index 70b61b9d7fd..41e3f81226c 100644 --- a/api/store/mocks/store.go +++ b/api/store/mocks/store.go @@ -552,6 +552,72 @@ func (_m *Store) GetStats(ctx context.Context, tenantID string) (*models.Stats, return r0, r1 } +// MembershipInvitationCreate provides a mock function with given fields: ctx, invitation +func (_m *Store) MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error { + ret := _m.Called(ctx, invitation) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationCreate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.MembershipInvitation) error); ok { + r0 = rf(ctx, invitation) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MembershipInvitationResolve provides a mock function with given fields: ctx, tenantID, userID +func (_m *Store) MembershipInvitationResolve(ctx context.Context, tenantID string, userID string) (*models.MembershipInvitation, error) { + ret := _m.Called(ctx, tenantID, userID) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationResolve") + } + + var r0 *models.MembershipInvitation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*models.MembershipInvitation, error)); ok { + return rf(ctx, tenantID, userID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *models.MembershipInvitation); ok { + r0 = rf(ctx, tenantID, userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.MembershipInvitation) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, tenantID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MembershipInvitationUpdate provides a mock function with given fields: ctx, invitation +func (_m *Store) MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error { + ret := _m.Called(ctx, invitation) + + if len(ret) == 0 { + panic("no return value specified for MembershipInvitationUpdate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.MembershipInvitation) error); ok { + r0 = rf(ctx, invitation) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // NamespaceConflicts provides a mock function with given fields: ctx, target func (_m *Store) NamespaceConflicts(ctx context.Context, target *models.NamespaceConflicts) ([]string, bool, error) { ret := _m.Called(ctx, target) diff --git a/api/store/mongo/fixtures/membership_invitations.json b/api/store/mongo/fixtures/membership_invitations.json new file mode 100644 index 00000000000..1414953a83c --- /dev/null +++ b/api/store/mongo/fixtures/membership_invitations.json @@ -0,0 +1,40 @@ +{ + "membership_invitations": { + "507f1f77bcf86cd799439012": { + "tenant_id": "00000000-0000-4000-0000-000000000000", + "user_id": "6509e169ae6144b2f56bf288", + "invited_by": "507f1f77bcf86cd799439011", + "role": "observer", + "status": "pending", + "created_at": "2023-01-01T12:00:00.000Z", + "updated_at": "2023-01-02T12:00:00.000Z", + "status_updated_at": "2023-01-01T12:00:00.000Z", + "expires_at": "2023-01-08T12:00:00.000Z", + "invitations": 1 + }, + "507f1f77bcf86cd799439013": { + "tenant_id": "00000000-0000-4001-0000-000000000000", + "user_id": "608f32a2c7351f001f6475e0", + "invited_by": "6509e169ae6144b2f56bf288", + "role": "administrator", + "status": "accepted", + "created_at": "2023-01-05T12:00:00.000Z", + "updated_at": "2023-01-06T12:00:00.000Z", + "status_updated_at": "2023-01-06T12:00:00.000Z", + "expires_at": "2023-01-12T12:00:00.000Z", + "invitations": 2 + }, + "507f1f77bcf86cd799439014": { + "tenant_id": "00000000-0000-4000-0000-000000000000", + "user_id": "507f1f77bcf86cd799439011", + "invited_by": "6509e169ae6144b2f56bf288", + "role": "observer", + "status": "pending", + "created_at": "2023-01-07T12:00:00.000Z", + "updated_at": "2023-01-07T12:00:00.000Z", + "status_updated_at": "2023-01-07T12:00:00.000Z", + "expires_at": "2023-01-14T12:00:00.000Z", + "invitations": 1 + } + } +} diff --git a/api/store/mongo/fixtures/namespaces.json b/api/store/mongo/fixtures/namespaces.json index 9c25f107889..81ac29e8156 100644 --- a/api/store/mongo/fixtures/namespaces.json +++ b/api/store/mongo/fixtures/namespaces.json @@ -7,14 +7,12 @@ { "id": "507f1f77bcf86cd799439011", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" }, { "id": "6509e169ae6144b2f56bf288", "added_at": "2023-01-01T12:00:00.000Z", - "role": "observer", - "status": "pending" + "role": "observer" } ], "name": "namespace-1", @@ -35,14 +33,12 @@ { "id": "6509e169ae6144b2f56bf288", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" }, { "id": "907f1f77bcf86cd799439022", "added_at": "2023-01-01T12:00:00.000Z", - "role": "operator", - "status": "accepted" + "role": "operator" } ], "name": "namespace-2", @@ -63,8 +59,7 @@ { "id": "657b0e3bff780d625f74e49a", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" } ], "name": "namespace-3", @@ -85,8 +80,7 @@ { "id": "6577267d8752d05270a4c07d", "added_at": "2023-01-01T12:00:00.000Z", - "role": "owner", - "status": "accepted" + "role": "owner" } ], "name": "namespace-4", diff --git a/api/store/mongo/member.go b/api/store/mongo/member.go index 614f1415ad8..efbc5ada6c8 100644 --- a/api/store/mongo/member.go +++ b/api/store/mongo/member.go @@ -22,11 +22,9 @@ func (s *Store) NamespaceCreateMembership(ctx context.Context, tenantID string, } memberBson := bson.M{ - "id": member.ID, - "added_at": member.AddedAt, - "expires_at": member.ExpiresAt, - "role": member.Role, - "status": member.Status, + "id": member.ID, + "added_at": member.AddedAt, + "role": member.Role, } res, err := s.db. @@ -51,11 +49,9 @@ func (s *Store) NamespaceUpdateMembership(ctx context.Context, tenantID string, filter := bson.M{"tenant_id": tenantID, "members": bson.M{"$elemMatch": bson.M{"id": member.ID}}} memberBson := bson.M{ - "members.$.id": member.ID, - "members.$.added_at": member.AddedAt, - "members.$.expires_at": member.ExpiresAt, - "members.$.role": member.Role, - "members.$.status": member.Status, + "members.$.id": member.ID, + "members.$.added_at": member.AddedAt, + "members.$.role": member.Role, } ns, err := s.db.Collection("namespaces").UpdateOne(ctx, filter, bson.M{"$set": memberBson}) diff --git a/api/store/mongo/member_test.go b/api/store/mongo/member_test.go index 3ed8462bbbc..997ef26080d 100644 --- a/api/store/mongo/member_test.go +++ b/api/store/mongo/member_test.go @@ -30,9 +30,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "fails when tenant is not found", tenantID: "nonexistent", member: &models.Member{ - ID: "6509de884238881ac1b2b289", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509de884238881ac1b2b289", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: store.ErrNoDocuments}, @@ -41,9 +40,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "fails when member has already been added", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "6509e169ae6144b2f56bf288", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: mongo.ErrNamespaceDuplicatedMember}, @@ -52,9 +50,8 @@ func TestNamespaceCreateMembership(t *testing.T) { description: "succeeds when tenant is found", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "6509de884238881ac1b2b289", - Role: authorizer.RoleObserver, - Status: models.MemberStatusAccepted, + ID: "6509de884238881ac1b2b289", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: nil}, @@ -97,9 +94,8 @@ func TestNamespaceUpdateMembership(t *testing.T) { description: "fails when user is not found", tenantID: "00000000-0000-4000-0000-000000000000", member: &models.Member{ - ID: "000000000000000000000000", - Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, + ID: "000000000000000000000000", + Role: authorizer.RoleObserver, }, fixtures: []string{fixtureNamespaces}, expected: Expected{err: mongo.ErrUserNotFound}, @@ -110,7 +106,6 @@ func TestNamespaceUpdateMembership(t *testing.T) { member: &models.Member{ ID: "6509e169ae6144b2f56bf288", Role: authorizer.RoleAdministrator, - Status: models.MemberStatusPending, AddedAt: time.Now(), }, fixtures: []string{fixtureNamespaces}, @@ -138,7 +133,6 @@ func TestNamespaceUpdateMembership(t *testing.T) { require.Equal(t, 2, len(namespace.Members)) require.Equal(t, tc.member.ID, namespace.Members[1].ID) require.Equal(t, tc.member.Role, namespace.Members[1].Role) - require.Equal(t, tc.member.Status, namespace.Members[1].Status) }) } } diff --git a/api/store/mongo/membership-invitation.go b/api/store/mongo/membership-invitation.go new file mode 100644 index 00000000000..2b0d6b11379 --- /dev/null +++ b/api/store/mongo/membership-invitation.go @@ -0,0 +1,146 @@ +package mongo + +import ( + "context" + + "github.com/shellhub-io/shellhub/api/store" + "github.com/shellhub-io/shellhub/pkg/clock" + "github.com/shellhub-io/shellhub/pkg/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func (s *Store) MembershipInvitationCreate(ctx context.Context, invitation *models.MembershipInvitation) error { + now := clock.Now() + invitation.CreatedAt = now + invitation.UpdatedAt = now + invitation.StatusUpdatedAt = now + + bsonBytes, err := bson.Marshal(invitation) + if err != nil { + return FromMongoError(err) + } + + doc := make(bson.M) + if err := bson.Unmarshal(bsonBytes, &doc); err != nil { + return FromMongoError(err) + } + + objID := primitive.NewObjectID() + doc["_id"] = objID + doc["user_id"], _ = primitive.ObjectIDFromHex(invitation.UserID) + doc["invited_by"], _ = primitive.ObjectIDFromHex(invitation.InvitedBy) + + if _, err := s.db.Collection("membership_invitations").InsertOne(ctx, doc); err != nil { + return FromMongoError(err) + } + + invitation.ID = objID.Hex() + + return nil +} + +func (s *Store) MembershipInvitationResolve(ctx context.Context, tenantID, userID string) (*models.MembershipInvitation, error) { + userObjID, _ := primitive.ObjectIDFromHex(userID) + + pipeline := []bson.M{ + { + "$match": bson.M{"tenant_id": tenantID, "user_id": userObjID}, + }, + { + "$sort": bson.D{{Key: "_id", Value: -1}}, + }, + { + "$limit": 1, + }, + { + "$lookup": bson.M{ + "from": "namespaces", + "localField": "tenant_id", + "foreignField": "tenant_id", + "as": "namespace", + }, + }, + { + "$lookup": bson.M{ + "from": "users", + "localField": "user_id", + "foreignField": "_id", + "as": "user", + }, + }, + { + "$lookup": bson.M{ + "from": "user_invitations", + "localField": "user_id", + "foreignField": "_id", + "as": "user_invitation", + }, + }, + { + "$addFields": bson.M{ + "namespace_name": bson.M{"$arrayElemAt": bson.A{"$namespace.name", 0}}, + "user_email": bson.M{ + "$ifNull": bson.A{ + bson.M{"$arrayElemAt": bson.A{"$user.email", 0}}, + bson.M{"$arrayElemAt": bson.A{"$user_invitation.email", 0}}, + }, + }, + }, + }, + { + "$project": bson.M{ + "namespace": 0, + "user": 0, + "user_invitation": 0, + }, + }, + } + + cursor, err := s.db.Collection("membership_invitations").Aggregate(ctx, pipeline) + if err != nil { + return nil, FromMongoError(err) + } + defer cursor.Close(ctx) + + if !cursor.Next(ctx) { + return nil, store.ErrNoDocuments + } + + invitation := &models.MembershipInvitation{} + if err := cursor.Decode(invitation); err != nil { + return nil, FromMongoError(err) + } + + return invitation, nil +} + +func (s *Store) MembershipInvitationUpdate(ctx context.Context, invitation *models.MembershipInvitation) error { + invitation.UpdatedAt = clock.Now() + + bsonBytes, err := bson.Marshal(invitation) + if err != nil { + return FromMongoError(err) + } + + doc := make(bson.M) + if err := bson.Unmarshal(bsonBytes, &doc); err != nil { + return FromMongoError(err) + } + + delete(doc, "_id") + doc["user_id"], _ = primitive.ObjectIDFromHex(invitation.UserID) + doc["invited_by"], _ = primitive.ObjectIDFromHex(invitation.InvitedBy) + + objID, _ := primitive.ObjectIDFromHex(invitation.ID) + r, err := s.db.Collection("membership_invitations").UpdateOne(ctx, bson.M{"_id": objID}, bson.M{"$set": doc}) + if err != nil { + return FromMongoError(err) + } + + if r.MatchedCount == 0 { + return store.ErrNoDocuments + } + + return nil +} diff --git a/api/store/mongo/membership-invitation_test.go b/api/store/mongo/membership-invitation_test.go new file mode 100644 index 00000000000..fbcc5261884 --- /dev/null +++ b/api/store/mongo/membership-invitation_test.go @@ -0,0 +1,295 @@ +package mongo_test + +import ( + "context" + "testing" + "time" + + "github.com/shellhub-io/shellhub/api/store" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/clock" + clockmock "github.com/shellhub-io/shellhub/pkg/clock/mocks" + "github.com/shellhub-io/shellhub/pkg/models" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestStore_MembershipInvitationCreate(t *testing.T) { + mockClock := new(clockmock.Clock) + clock.DefaultBackend = mockClock + + now := time.Now() + mockClock.On("Now").Return(now) + expiresAt := now.Add(7 * 24 * time.Hour) + + cases := []struct { + description string + invitation *models.MembershipInvitation + fixtures []string + expected map[string]any + }{ + { + description: "succeeds creating new invitation", + invitation: &models.MembershipInvitation{ + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + ExpiresAt: &expiresAt, + Invitations: 1, + }, + fixtures: []string{}, + expected: map[string]any{ + "tenant_id": "00000000-0000-4000-0000-000000000000", + "role": "observer", + "status": "pending", + "created_at": primitive.NewDateTimeFromTime(now), + "updated_at": primitive.NewDateTimeFromTime(now), + "status_updated_at": primitive.NewDateTimeFromTime(now), + "invitations": int32(1), + }, + }, + { + description: "succeeds creating invitation with ID", + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439020", + TenantID: "00000000-0000-4001-0000-000000000000", + UserID: "907f1f77bcf86cd799439022", + InvitedBy: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + ExpiresAt: &expiresAt, + Invitations: 2, + }, + fixtures: []string{}, + expected: map[string]any{ + "tenant_id": "00000000-0000-4001-0000-000000000000", + "role": "administrator", + "status": "accepted", + "created_at": primitive.NewDateTimeFromTime(now), + "updated_at": primitive.NewDateTimeFromTime(now), + "status_updated_at": primitive.NewDateTimeFromTime(now), + "invitations": int32(2), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + err := s.MembershipInvitationCreate(ctx, tc.invitation) + require.NoError(tt, err) + require.NotEmpty(tt, tc.invitation.ID) + + objID, _ := primitive.ObjectIDFromHex(tc.invitation.ID) + userObjID, _ := primitive.ObjectIDFromHex(tc.invitation.UserID) + invitedByObjID, _ := primitive.ObjectIDFromHex(tc.invitation.InvitedBy) + + tmpInvitation := make(map[string]any) + require.NoError(tt, db.Collection("membership_invitations").FindOne(ctx, bson.M{"_id": objID}).Decode(&tmpInvitation)) + + require.Equal(tt, objID, tmpInvitation["_id"]) + require.Equal(tt, userObjID, tmpInvitation["user_id"]) + require.Equal(tt, invitedByObjID, tmpInvitation["invited_by"]) + + for field, expectedValue := range tc.expected { + require.Equal(tt, expectedValue, tmpInvitation[field]) + } + }) + } +} + +func TestStore_MembershipInvitationResolve(t *testing.T) { + type Expected struct { + invitation *models.MembershipInvitation + err error + } + + cases := []struct { + description string + tenantID string + userID string + fixtures []string + expected Expected + }{ + { + description: "fails when invitation not found", + tenantID: "00000000-0000-4000-0000-000000000000", + userID: "000000000000000000000000", + fixtures: []string{fixtureMembershipInvitations, fixtureNamespaces, fixtureUsers}, + expected: Expected{invitation: nil, err: store.ErrNoDocuments}, + }, + { + description: "succeeds fetching email from users collection", + tenantID: "00000000-0000-4000-0000-000000000000", + userID: "6509e169ae6144b2f56bf288", + fixtures: []string{fixtureMembershipInvitations, fixtureNamespaces, fixtureUsers}, + expected: Expected{ + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439012", + TenantID: "00000000-0000-4000-0000-000000000000", + NamespaceName: "namespace-1", + UserID: "6509e169ae6144b2f56bf288", + UserEmail: "maria.garcia@test.com", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + }, + err: nil, + }, + }, + { + description: "succeeds fetching email from user_invitations collection", + tenantID: "00000000-0000-4000-0000-000000000000", + userID: "507f1f77bcf86cd799439011", + fixtures: []string{fixtureMembershipInvitations, fixtureNamespaces, fixtureUserInvitations}, + expected: Expected{ + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439014", + TenantID: "00000000-0000-4000-0000-000000000000", + NamespaceName: "namespace-1", + UserID: "507f1f77bcf86cd799439011", + UserEmail: "jane.doe@test.com", + InvitedBy: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + }, + err: nil, + }, + }, + { + description: "returns most recent when multiple invitations exist", + tenantID: "00000000-0000-4001-0000-000000000000", + userID: "608f32a2c7351f001f6475e0", + fixtures: []string{fixtureMembershipInvitations, fixtureNamespaces, fixtureUsers}, + expected: Expected{ + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439013", + TenantID: "00000000-0000-4001-0000-000000000000", + NamespaceName: "namespace-2", + UserID: "608f32a2c7351f001f6475e0", + UserEmail: "jane.smith@test.com", + InvitedBy: "6509e169ae6144b2f56bf288", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + }, + err: nil, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + invitation, err := s.MembershipInvitationResolve(ctx, tc.tenantID, tc.userID) + + if tc.expected.err != nil { + require.Equal(tt, tc.expected.err, err) + require.Nil(tt, invitation) + } else { + require.NoError(tt, err) + require.NotNil(tt, invitation) + require.Equal(tt, tc.expected.invitation.ID, invitation.ID) + require.Equal(tt, tc.expected.invitation.TenantID, invitation.TenantID) + require.Equal(tt, tc.expected.invitation.NamespaceName, invitation.NamespaceName) + require.Equal(tt, tc.expected.invitation.UserID, invitation.UserID) + require.Equal(tt, tc.expected.invitation.UserEmail, invitation.UserEmail) + require.Equal(tt, tc.expected.invitation.InvitedBy, invitation.InvitedBy) + require.Equal(tt, tc.expected.invitation.Role, invitation.Role) + require.Equal(tt, tc.expected.invitation.Status, invitation.Status) + } + }) + } +} + +func TestStore_MembershipInvitationUpdate(t *testing.T) { + mockClock := new(clockmock.Clock) + clock.DefaultBackend = mockClock + + now := time.Now() + mockClock.On("Now").Return(now) + + type Expected struct { + err error + } + + cases := []struct { + description string + invitation *models.MembershipInvitation + fixtures []string + expected Expected + }{ + { + description: "fails when invitation not found", + invitation: &models.MembershipInvitation{ + ID: "000000000000000000000000", + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleObserver, + Status: models.MembershipInvitationStatusPending, + StatusUpdatedAt: now, + Invitations: 2, + }, + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{err: store.ErrNoDocuments}, + }, + { + description: "succeeds when invitation found", + invitation: &models.MembershipInvitation{ + ID: "507f1f77bcf86cd799439012", + TenantID: "00000000-0000-4000-0000-000000000000", + UserID: "6509e169ae6144b2f56bf288", + InvitedBy: "507f1f77bcf86cd799439011", + Role: authorizer.RoleAdministrator, + Status: models.MembershipInvitationStatusAccepted, + StatusUpdatedAt: now, + Invitations: 3, + }, + fixtures: []string{fixtureMembershipInvitations}, + expected: Expected{err: nil}, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + ctx := context.Background() + + require.NoError(tt, srv.Apply(tc.fixtures...)) + tt.Cleanup(func() { + require.NoError(tt, srv.Reset()) + }) + + err := s.MembershipInvitationUpdate(ctx, tc.invitation) + + if tc.expected.err != nil { + require.Equal(tt, tc.expected.err, err) + } else { + require.NoError(tt, err) + + objID, _ := primitive.ObjectIDFromHex(tc.invitation.ID) + updatedInvitation := &models.MembershipInvitation{} + require.NoError(tt, db.Collection("membership_invitations").FindOne(ctx, bson.M{"_id": objID}).Decode(updatedInvitation)) + + require.Equal(tt, tc.invitation.Role, updatedInvitation.Role) + require.Equal(tt, tc.invitation.Status, updatedInvitation.Status) + require.Equal(tt, tc.invitation.Invitations, updatedInvitation.Invitations) + require.Equal(tt, primitive.NewDateTimeFromTime(now), primitive.NewDateTimeFromTime(updatedInvitation.UpdatedAt)) + } + }) + } +} diff --git a/api/store/mongo/migrations/main.go b/api/store/mongo/migrations/main.go index 3ac04010fb0..cd40a56e77d 100644 --- a/api/store/mongo/migrations/main.go +++ b/api/store/mongo/migrations/main.go @@ -127,6 +127,8 @@ func GenerateMigrations() []migrate.Migration { migration115, migration116, migration117, + migration118, + migration119, } } diff --git a/api/store/mongo/migrations/migration_118.go b/api/store/mongo/migrations/migration_118.go new file mode 100644 index 00000000000..695f554ed9b --- /dev/null +++ b/api/store/mongo/migrations/migration_118.go @@ -0,0 +1,129 @@ +package migrations + +import ( + "context" + + log "github.com/sirupsen/logrus" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +var migration118 = migrate.Migration{ + Version: 118, + Description: "Migrate member invitations from namespaces members array to membership_invitations collection", + Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 118, + "action": "Up", + }).Info("Applying migration up") + + session, err := db.Client().StartSession() + if err != nil { + return err + } + defer session.EndSession(ctx) + + _, err = session.WithTransaction(ctx, func(sCtx mongo.SessionContext) (any, error) { + cursor, err := db.Collection("namespaces").Find(sCtx, bson.M{}) + if err != nil { + log.WithError(err).Error("Failed to find namespaces") + + return nil, err + } + + defer cursor.Close(sCtx) + + invitations := make([]any, 0) + namespacesToUpdate := make([]bson.M, 0) + + for cursor.Next(sCtx) { + namespace := make(bson.M) + if err := cursor.Decode(&namespace); err != nil { + log.WithError(err).Error("Failed to decode namespace document") + + return nil, err + } + + if members, ok := namespace["members"].(bson.A); ok { + updatedMembers := make(bson.A, 0) + for _, m := range members { + if member, ok := m.(bson.M); ok { + if member["role"] != "owner" { + invitations = append( + invitations, + bson.M{ + "tenant_id": namespace["tenant_id"], + "user_id": member["id"], + "invited_by": namespace["owner"], + "role": member["role"], + "status": member["status"], + "created_at": member["added_at"], + "updated_at": member["added_at"], + "status_updated_at": member["added_at"], + "expires_at": member["expires_at"], + "invitations": 1, + }, + ) + } + + if member["status"] == "accepted" { + member := bson.M{"id": member["id"], "added_at": member["added_at"], "role": member["role"]} + updatedMembers = append(updatedMembers, member) + } + } + } + + namespace["members"] = updatedMembers + namespacesToUpdate = append(namespacesToUpdate, namespace) + } + } + + if err := cursor.Err(); err != nil { + log.WithError(err).Error("Cursor error while iterating namespaces") + + return nil, err + } + + if len(invitations) > 0 { + if _, err = db.Collection("membership_invitations").InsertMany(sCtx, invitations); err != nil { + log.WithError(err).Error("Failed to insert membership invitations") + + return nil, err + } + + log.WithField("count", len(invitations)).Info("Successfully migrated member invitations to membership_invitations collection") + } else { + log.Info("No member invitations found to migrate") + } + + for _, ns := range namespacesToUpdate { + nsID := ns["_id"] + if _, err = db.Collection("namespaces").ReplaceOne(sCtx, bson.M{"_id": nsID}, ns); err != nil { + log.WithError(err).Error("Failed to update namespace") + + return nil, err + } + } + + if len(namespacesToUpdate) > 0 { + log.WithField("count", len(namespacesToUpdate)).Info("Successfully updated namespaces with cleaned members") + } + + return nil, nil + }) + + return err + }), + + Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 118, + "action": "Down", + }).Warning("Migration down is not implemented - this migration cannot be reversed safely") + + return nil + }), +} diff --git a/api/store/mongo/migrations/migration_118_test.go b/api/store/mongo/migrations/migration_118_test.go new file mode 100644 index 00000000000..0e2b9da81e1 --- /dev/null +++ b/api/store/mongo/migrations/migration_118_test.go @@ -0,0 +1,169 @@ +package migrations + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func TestMigration118Up(t *testing.T) { + ctx := context.Background() + + cases := []struct { + description string + setup func() error + verify func(tt *testing.T) + }{ + { + description: "succeeds migrating namespace members to membership_invitations collection", + setup: func() error { + ownerID := primitive.NewObjectID() + memberID1 := primitive.NewObjectID() + memberID2 := primitive.NewObjectID() + + namespaces := []bson.M{ + { + "_id": primitive.NewObjectID(), + "name": "test-namespace-1", + "owner": ownerID, + "tenant_id": "tenant-1", + "members": bson.A{ + bson.M{ + "id": ownerID, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "owner", + "status": "accepted", + "expires_at": nil, + }, + bson.M{ + "id": memberID1, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "observer", + "status": "pending", + "expires_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp().Add(7 * 24 * 60 * 60 * 1000)), + }, + bson.M{ + "id": memberID2, + "added_at": primitive.NewDateTimeFromTime(primitive.NewObjectID().Timestamp()), + "role": "administrator", + "status": "accepted", + "expires_at": nil, + }, + }, + }, + } + + _, err := c.Database("test").Collection("namespaces").InsertMany(ctx, []any{namespaces[0]}) + + return err + }, + verify: func(tt *testing.T) { + cursor, err := c.Database("test").Collection("membership_invitations").Find(ctx, bson.M{}) + require.NoError(tt, err) + + invitations := make([]bson.M, 0) + require.NoError(tt, cursor.All(ctx, &invitations)) + require.Equal(tt, 2, len(invitations)) + + ownerFound := false + for _, invitation := range invitations { + require.NotNil(tt, invitation["_id"]) + require.Equal(tt, "tenant-1", invitation["tenant_id"]) + require.NotNil(tt, invitation["user_id"]) + require.NotNil(tt, invitation["invited_by"]) + require.NotNil(tt, invitation["role"]) + require.NotNil(tt, invitation["status"]) + require.NotNil(tt, invitation["created_at"]) + require.NotNil(tt, invitation["updated_at"]) + require.NotNil(tt, invitation["status_updated_at"]) + require.Equal(tt, int32(1), invitation["invitations"]) + + require.NotEqual(tt, "owner", invitation["role"]) + if invitation["role"] == "owner" { + ownerFound = true + } + } + require.False(tt, ownerFound, "Owner should not have an invitation created") + + namespaceCursor, err := c.Database("test").Collection("namespaces").Find(ctx, bson.M{"tenant_id": "tenant-1"}) + require.NoError(tt, err) + + namespaces := make([]bson.M, 0) + require.NoError(tt, namespaceCursor.All(ctx, &namespaces)) + require.Equal(tt, 1, len(namespaces)) + + namespace := namespaces[0] + members, ok := namespace["members"].(bson.A) + require.True(tt, ok) + require.Equal(tt, 2, len(members)) + + for _, m := range members { + member, ok := m.(bson.M) + require.True(tt, ok) + require.NotNil(tt, member["id"]) + require.NotNil(tt, member["added_at"]) + require.NotNil(tt, member["role"]) + require.Nil(tt, member["status"]) + require.Nil(tt, member["expires_at"]) + } + }, + }, + { + description: "handles namespace with no members gracefully", + setup: func() error { + namespaces := []bson.M{ + { + "_id": primitive.NewObjectID(), + "name": "empty-namespace", + "owner": primitive.NewObjectID(), + "tenant_id": "tenant-empty", + "members": bson.A{}, + }, + } + + _, err := c.Database("test").Collection("namespaces").InsertMany(ctx, []any{namespaces[0]}) + + return err + }, + verify: func(tt *testing.T) { + count, err := c.Database("test").Collection("membership_invitations").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), count) + + namespaceCount, err := c.Database("test").Collection("namespaces").CountDocuments(ctx, bson.M{"tenant_id": "tenant-empty"}) + require.NoError(tt, err) + require.Equal(tt, int64(1), namespaceCount) + }, + }, + { + description: "handles empty namespaces collection gracefully", + setup: func() error { + return nil + }, + verify: func(tt *testing.T) { + count, err := c.Database("test").Collection("membership_invitations").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), count) + + namespaceCount, err := c.Database("test").Collection("namespaces").CountDocuments(ctx, bson.M{}) + require.NoError(tt, err) + require.Equal(tt, int64(0), namespaceCount) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.description, func(tt *testing.T) { + tt.Cleanup(func() { require.NoError(tt, srv.Reset()) }) + + require.NoError(tt, tc.setup()) + migrates := migrate.NewMigrate(c.Database("test"), GenerateMigrations()[117]) + require.NoError(tt, migrates.Up(ctx, migrate.AllAvailable)) + tc.verify(tt) + }) + } +} diff --git a/api/store/mongo/migrations/migration_119.go b/api/store/mongo/migrations/migration_119.go new file mode 100644 index 00000000000..8483c2a7b5a --- /dev/null +++ b/api/store/mongo/migrations/migration_119.go @@ -0,0 +1,95 @@ +package migrations + +import ( + "context" + + log "github.com/sirupsen/logrus" + migrate "github.com/xakep666/mongo-migrate" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var migration119 = migrate.Migration{ + Version: 119, + Description: "Create indexes on membership_invitations collection", + Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 119, + "action": "Up", + }).Info("Applying migration up") + + indexes := []struct { + name string + model mongo.IndexModel + }{ + { + name: "tenant_user_status_pending_unique", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "user_id", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index(). + SetName("tenant_user_status_pending_unique"). + SetUnique(true). + SetPartialFilterExpression(bson.M{"status": "pending"}), + }, + }, + { + name: "tenant_user_created_at", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "tenant_id", Value: 1}, + {Key: "user_id", Value: 1}, + }, + Options: options.Index().SetName("tenant_user_created_at"), + }, + }, + { + name: "user_status", + model: mongo.IndexModel{ + Keys: bson.D{ + {Key: "user_id", Value: 1}, + {Key: "status", Value: 1}, + }, + Options: options.Index().SetName("user_status"), + }, + }, + } + + for _, ix := range indexes { + if _, err := db.Collection("membership_invitations").Indexes().CreateOne(ctx, ix.model); err != nil { + log.WithError(err).WithField("index", ix.name).Error("Failed to create index") + + return err + } + } + + log.Info("Successfully created indexes on membership_invitations collection") + + return nil + }), + Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error { + log.WithFields(log.Fields{ + "component": "migration", + "version": 119, + "action": "Down", + }).Info("Applying migration down") + + indexes := []string{"tenant_user_status_pending_unique", "tenant_user_created_at", "user_status"} + for _, ix := range indexes { + if _, err := db.Collection("membership_invitations").Indexes().DropOne(ctx, ix); err != nil { + log.WithError(err).WithField("index", ix).Error("Failed to drop index") + + return err + } + } + + log.Info("Successfully dropped indexes from membership_invitations collection") + + return nil + }), +} diff --git a/api/store/mongo/migrations/migration_72.go b/api/store/mongo/migrations/migration_72.go index e99e3f6a7a7..d888c752c07 100644 --- a/api/store/mongo/migrations/migration_72.go +++ b/api/store/mongo/migrations/migration_72.go @@ -2,7 +2,9 @@ package migrations import ( "context" + "time" + "github.com/shellhub-io/shellhub/pkg/api/authorizer" "github.com/shellhub-io/shellhub/pkg/models" log "github.com/sirupsen/logrus" migrate "github.com/xakep666/mongo-migrate" @@ -10,6 +12,21 @@ import ( "go.mongodb.org/mongo-driver/mongo" ) +// Member struct as it was when migration 72 was created (with Status field) +type memberForMigration72 struct { + ID string `json:"id,omitempty" bson:"id,omitempty"` + AddedAt time.Time `json:"added_at" bson:"added_at"` + Email string `json:"email" bson:"email,omitempty" validate:"email"` + Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` + Status string `json:"status" bson:"status"` +} + +// Namespace struct for migration 72 with the old Member type +type namespaceForMigration72 struct { + models.Namespace `bson:",inline"` + Members []memberForMigration72 `json:"members" bson:"members"` +} + var migration72 = migrate.Migration{ Version: 72, Description: "Adding the 'members.$.status' attribute to the namespace if it does not already exist.", @@ -39,7 +56,7 @@ var migration72 = migrate.Migration{ updateModels := make([]mongo.WriteModel, 0) for cursor.Next(ctx) { - namespace := new(models.Namespace) + namespace := new(namespaceForMigration72) if err := cursor.Decode(namespace); err != nil { return err } @@ -49,7 +66,7 @@ var migration72 = migrate.Migration{ updateModel := mongo. NewUpdateOneModel(). SetFilter(bson.M{"tenant_id": namespace.TenantID, "members": bson.M{"$elemMatch": bson.M{"id": m.ID}}}). - SetUpdate(bson.M{"$set": bson.M{"members.$.status": models.DeviceStatusAccepted}}) + SetUpdate(bson.M{"$set": bson.M{"members.$.status": "accepted"}}) updateModels = append(updateModels, updateModel) } @@ -90,7 +107,7 @@ var migration72 = migrate.Migration{ updateModels := make([]mongo.WriteModel, 0) for cursor.Next(ctx) { - namespace := new(models.Namespace) + namespace := new(namespaceForMigration72) if err := cursor.Decode(namespace); err != nil { return err } diff --git a/api/store/mongo/namespace.go b/api/store/mongo/namespace.go index d15bd741bf7..8281e6aca81 100644 --- a/api/store/mongo/namespace.go +++ b/api/store/mongo/namespace.go @@ -30,9 +30,6 @@ func (s *Store) NamespaceList(ctx context.Context, opts ...store.QueryOption) ([ "members": bson.M{ "$elemMatch": bson.M{ "id": user.ID, - "status": bson.M{ - "$ne": models.MemberStatusPending, - }, }, }, }, diff --git a/api/store/mongo/namespace_test.go b/api/store/mongo/namespace_test.go index fa91041bbf3..6a5266385d2 100644 --- a/api/store/mongo/namespace_test.go +++ b/api/store/mongo/namespace_test.go @@ -53,13 +53,11 @@ func TestNamespaceList(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, }, }, MaxDevices: -1, @@ -79,13 +77,11 @@ func TestNamespaceList(t *testing.T) { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "907f1f77bcf86cd799439022", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOperator, - Status: models.MemberStatusAccepted, }, }, MaxDevices: 10, @@ -105,7 +101,6 @@ func TestNamespaceList(t *testing.T) { ID: "657b0e3bff780d625f74e49a", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, }, MaxDevices: 3, @@ -125,7 +120,6 @@ func TestNamespaceList(t *testing.T) { ID: "6577267d8752d05270a4c07d", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, }, MaxDevices: -1, @@ -206,14 +200,12 @@ func TestNamespaceResolve(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, Email: "john.doe@test.com", }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, Email: "maria.garcia@test.com", }, }, @@ -253,14 +245,12 @@ func TestNamespaceResolve(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, Email: "john.doe@test.com", }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, Email: "maria.garcia@test.com", }, }, @@ -327,13 +317,11 @@ func TestNamespaceGetPreferred(t *testing.T) { ID: "507f1f77bcf86cd799439011", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleOwner, - Status: models.MemberStatusAccepted, }, { ID: "6509e169ae6144b2f56bf288", AddedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), Role: authorizer.RoleObserver, - Status: models.MemberStatusPending, }, }, MaxDevices: -1, diff --git a/api/store/mongo/store_test.go b/api/store/mongo/store_test.go index d4a9cee074a..4920f6a8e74 100644 --- a/api/store/mongo/store_test.go +++ b/api/store/mongo/store_test.go @@ -24,18 +24,19 @@ var ( ) const ( - fixtureAPIKeys = "api-key" // Check "store.mongo.fixtures.api-keys" for fixture info - fixtureDevices = "devices" // Check "store.mongo.fixtures.devices" for fixture info - fixtureSessions = "sessions" // Check "store.mongo.fixtures.sessions" for fixture info - fixtureActiveSessions = "active_sessions" // Check "store.mongo.fixtures.active_sessions" for fixture info - fixtureFirewallRules = "firewall_rules" // Check "store.mongo.fixtures.firewall_rules" for fixture info - fixturePublicKeys = "public_keys" // Check "store.mongo.fixtures.public_keys" for fixture info - fixturePrivateKeys = "private_keys" // Check "store.mongo.fixtures.private_keys" for fixture info - fixtureUsers = "users" // Check "store.mongo.fixtures.users" for fixture iefo - fixtureNamespaces = "namespaces" // Check "store.mongo.fixtures.namespaces" for fixture info - fixtureRecoveryTokens = "recovery_tokens" // Check "store.mongo.fixtures.recovery_tokens" for fixture info - fixtureTags = "tags" // Check "store.mongo.fixtures.tags" for fixture info - fixtureUserInvitations = "user_invitations" // Check "store.mongo.fixtures.user_invitations" for fixture info + fixtureAPIKeys = "api-key" // Check "store.mongo.fixtures.api-keys" for fixture info + fixtureDevices = "devices" // Check "store.mongo.fixtures.devices" for fixture info + fixtureSessions = "sessions" // Check "store.mongo.fixtures.sessions" for fixture info + fixtureActiveSessions = "active_sessions" // Check "store.mongo.fixtures.active_sessions" for fixture info + fixtureFirewallRules = "firewall_rules" // Check "store.mongo.fixtures.firewall_rules" for fixture info + fixturePublicKeys = "public_keys" // Check "store.mongo.fixtures.public_keys" for fixture info + fixturePrivateKeys = "private_keys" // Check "store.mongo.fixtures.private_keys" for fixture info + fixtureUsers = "users" // Check "store.mongo.fixtures.users" for fixture iefo + fixtureNamespaces = "namespaces" // Check "store.mongo.fixtures.namespaces" for fixture info + fixtureRecoveryTokens = "recovery_tokens" // Check "store.mongo.fixtures.recovery_tokens" for fixture info + fixtureTags = "tags" // Check "store.mongo.fixtures.tags" for fixture info + fixtureUserInvitations = "user_invitations" // Check "store.mongo.fixtures.user_invitations" for fixture info + fixtureMembershipInvitations = "membership_invitations" // Check "store.mongo.fixtures.membership_invitations" for fixture info ) func TestMain(m *testing.M) { @@ -53,6 +54,13 @@ func TestMain(m *testing.M) { mongotest.SimpleConvertObjID("user_invitations", "_id"), mongotest.SimpleConvertTime("user_invitations", "created_at"), mongotest.SimpleConvertTime("user_invitations", "updated_at"), + mongotest.SimpleConvertObjID("membership_invitations", "_id"), + mongotest.SimpleConvertObjID("membership_invitations", "user_id"), + mongotest.SimpleConvertObjID("membership_invitations", "invited_by"), + mongotest.SimpleConvertTime("membership_invitations", "created_at"), + mongotest.SimpleConvertTime("membership_invitations", "updated_at"), + mongotest.SimpleConvertTime("membership_invitations", "status_updated_at"), + mongotest.SimpleConvertTime("membership_invitations", "expires_at"), mongotest.SimpleConvertObjID("public_keys", "_id"), mongotest.SimpleConvertBytes("public_keys", "data"), mongotest.SimpleConvertTime("public_keys", "created_at"), diff --git a/api/store/store.go b/api/store/store.go index 8ed8f43d276..f05b8a75a0e 100644 --- a/api/store/store.go +++ b/api/store/store.go @@ -9,6 +9,7 @@ type Store interface { UserInvitationsStore NamespaceStore MemberStore + MembershipInvitationsStore PublicKeyStore PrivateKeyStore StatsStore diff --git a/cli/services/namespaces.go b/cli/services/namespaces.go index 9d61d10625f..0cb52871a45 100644 --- a/cli/services/namespaces.go +++ b/cli/services/namespaces.go @@ -44,7 +44,6 @@ func (s *service) NamespaceCreate(ctx context.Context, input *inputs.NamespaceCr ID: user.ID, Role: authorizer.RoleOwner, AddedAt: clock.Now(), - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -88,7 +87,6 @@ func (s *service) NamespaceAddMember(ctx context.Context, input *inputs.MemberAd ID: user.ID, Role: input.Role, AddedAt: clock.Now(), - Status: models.MemberStatusAccepted, }); err != nil { return nil, ErrFailedNamespaceAddMember } diff --git a/cli/services/namespaces_test.go b/cli/services/namespaces_test.go index e990076d012..e83dbaf72c0 100644 --- a/cli/services/namespaces_test.go +++ b/cli/services/namespaces_test.go @@ -108,7 +108,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -152,7 +151,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -174,7 +172,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -215,7 +212,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -237,7 +233,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -278,7 +273,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -300,7 +294,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -341,7 +334,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -363,7 +355,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -404,7 +395,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -426,7 +416,6 @@ func TestNamespaceCreate(t *testing.T) { ID: "507f191e810c19729de860ea", Role: "owner", AddedAt: now, - Status: models.MemberStatusAccepted, }, }, Settings: &models.NamespaceSettings{ @@ -533,7 +522,6 @@ func TestNamespaceAddMember(t *testing.T) { ID: "507f191e810c19729de860ea", Role: authorizer.RoleObserver, AddedAt: now, - Status: models.MemberStatusAccepted, }).Return(nil).Once() }, expected: Expected{&models.Namespace{ diff --git a/gateway/nginx/conf.d/shellhub.conf b/gateway/nginx/conf.d/shellhub.conf index 0546c063584..65209b061cf 100644 --- a/gateway/nginx/conf.d/shellhub.conf +++ b/gateway/nginx/conf.d/shellhub.conf @@ -400,6 +400,55 @@ server { proxy_set_header X-Request-ID $request_id; } + {{ if $cfg.EnableCloud -}} + location /api/users/invitations { + set $upstream cloud:8080; + limit_req zone=api_limit{{ if $api_burst }} {{ $api_burst }}{{ end }} {{ $api_delay }}; + + auth_request /auth; + auth_request_set $tenant_id $upstream_http_x_tenant_id; + auth_request_set $username $upstream_http_x_username; + auth_request_set $id $upstream_http_x_id; + auth_request_set $api_key $upstream_http_x_api_key; + auth_request_set $role $upstream_http_x_role; + error_page 500 =401 /auth; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Api-Key $api_key; + proxy_set_header X-ID $id; + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Role $role; + proxy_set_header X-Tenant-ID $tenant_id; + proxy_set_header X-Username $username; + proxy_pass http://$upstream; + } + + location ~^/api/namespaces/[^/]+/invitations(/.*)?$ { + set $upstream cloud:8080; + limit_req zone=api_limit{{ if $api_burst }} {{ $api_burst }}{{ end }} {{ $api_delay }}; + + auth_request /auth; + auth_request_set $tenant_id $upstream_http_x_tenant_id; + auth_request_set $username $upstream_http_x_username; + auth_request_set $id $upstream_http_x_id; + auth_request_set $api_key $upstream_http_x_api_key; + auth_request_set $role $upstream_http_x_role; + error_page 500 =401 /auth; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $x_forwarded_port; + proxy_set_header X-Forwarded-Proto $x_forwarded_proto; + proxy_set_header X-Api-Key $api_key; + proxy_set_header X-ID $id; + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Role $role; + proxy_set_header X-Tenant-ID $tenant_id; + proxy_set_header X-Username $username; + proxy_pass http://$upstream; + } + {{ end -}} + {{ if $cfg.EnableCloud -}} location /api/announcements { set $upstream cloud:8080; diff --git a/openapi/spec/cloud-openapi.yaml b/openapi/spec/cloud-openapi.yaml index 82e32ffb2ca..65b84cbf8eb 100644 --- a/openapi/spec/cloud-openapi.yaml +++ b/openapi/spec/cloud-openapi.yaml @@ -112,12 +112,20 @@ paths: $ref: paths/api@connector@{uid}.yaml /api/connector/{uid}/info: $ref: paths/api@connector@{uid}@info.yaml - /api/namespaces/{tenant}/members/accept-invite: - $ref: paths/api@namespaces@{tenant}@members@accept-invite.yaml - /api/namespaces/{tenant}/members/invites: - $ref: paths/api@namespaces@{tenant}@members@invites.yaml + /api/namespaces/{tenant}/invitations/links: + $ref: paths/api@namespaces@{tenant}@invitations@links.yaml /api/namespaces/{tenant}/members/{id}/accept-invite: $ref: paths/api@namespaces@{tenant}@members@{id}@accept-invite.yaml + /api/namespaces/{tenant}/invitations/accept: + $ref: paths/api@namespaces@{tenant}@invitations@accept.yaml + /api/namespaces/{tenant}/invitations/decline: + $ref: paths/api@namespaces@{tenant}@invitations@decline.yaml + /api/namespaces/{tenant}/invitations/{user-id}: + $ref: paths/api@namespaces@{tenant}@invitations@{user-id}.yaml + /api/namespaces/{tenant}/invitations: + $ref: paths/api@namespaces@{tenant}@invitations.yaml + /api/users/invitations: + $ref: paths/api@users@invitations.yaml /api/namespaces/{tenant}/support: $ref: paths/api@namespaces@{tenant}@support.yaml /api/web-endpoints: diff --git a/openapi/spec/components/schemas/membershipInvitation.yaml b/openapi/spec/components/schemas/membershipInvitation.yaml new file mode 100644 index 00000000000..376170f9946 --- /dev/null +++ b/openapi/spec/components/schemas/membershipInvitation.yaml @@ -0,0 +1,65 @@ +type: object +description: A membership invitation to a namespace +properties: + namespace: + type: object + description: The namespace associated with this invitation + properties: + tenant_id: + description: The namespace tenant ID + type: string + example: "00000000-0000-4000-0000-000000000000" + name: + description: The namespace name + type: string + example: "my-namespace" + required: + - tenant_id + - name + user: + type: object + description: The invited user + properties: + id: + description: The ID of the invited user + type: string + example: "507f1f77bcf86cd799439011" + email: + description: The email of the invited user + type: string + example: "user@example.com" + required: + - id + - email + invited_by: + description: The ID of the user who sent the invitation + type: string + example: "507f1f77bcf86cd799439012" + created_at: + description: When the invitation was created + type: string + format: date-time + updated_at: + description: When the invitation was last updated + type: string + format: date-time + expires_at: + description: When the invitation expires + type: string + format: date-time + nullable: true + status: + description: The current status of the invitation + type: string + enum: + - pending + - accepted + - rejected + - cancelled + example: pending + status_updated_at: + description: When the status was last updated + type: string + format: date-time + role: + $ref: ./namespaceMemberRole.yaml diff --git a/openapi/spec/openapi.yaml b/openapi/spec/openapi.yaml index a2d64bf62ae..86cec087623 100644 --- a/openapi/spec/openapi.yaml +++ b/openapi/spec/openapi.yaml @@ -231,12 +231,22 @@ paths: $ref: paths/api@connector@{uid}.yaml /api/connector/{uid}/info: $ref: paths/api@connector@{uid}@info.yaml - /api/namespaces/{tenant}/members/accept-invite: - $ref: paths/api@namespaces@{tenant}@members@accept-invite.yaml - /api/namespaces/{tenant}/members/invites: - $ref: paths/api@namespaces@{tenant}@members@invites.yaml + # Lookup user status + # TODO: rename this endpoint /api/namespaces/{tenant}/members/{id}/accept-invite: $ref: paths/api@namespaces@{tenant}@members@{id}@accept-invite.yaml + /api/namespaces/{tenant}/invitations/links: + $ref: paths/api@namespaces@{tenant}@invitations@links.yaml + /api/namespaces/{tenant}/invitations/accept: + $ref: paths/api@namespaces@{tenant}@invitations@accept.yaml + /api/namespaces/{tenant}/invitations/decline: + $ref: paths/api@namespaces@{tenant}@invitations@decline.yaml + /api/namespaces/{tenant}/invitations/{user-id}: + $ref: paths/api@namespaces@{tenant}@invitations@{user-id}.yaml + /api/namespaces/{tenant}/invitations: + $ref: paths/api@namespaces@{tenant}@invitations.yaml + /api/users/invitations: + $ref: paths/api@users@invitations.yaml /api/namespaces/{tenant}/support: $ref: paths/api@namespaces@{tenant}@support.yaml /api/web-endpoints: diff --git a/openapi/spec/paths/api@namespaces@{tenant}@invitations.yaml b/openapi/spec/paths/api@namespaces@{tenant}@invitations.yaml new file mode 100644 index 00000000000..b626cc66b62 --- /dev/null +++ b/openapi/spec/paths/api@namespaces@{tenant}@invitations.yaml @@ -0,0 +1,48 @@ +get: + operationId: getNamespaceMembershipInvitationList + summary: Get membership invitations for a namespace + description: | + Returns a paginated list of membership invitations for the specified namespace. + This endpoint allows namespace administrators to view all pending invitations. + tags: + - cloud + - members + - namespaces + security: + - jwt: [] + parameters: + - $ref: ../components/parameters/path/namespaceTenantIDPath.yaml + - name: filter + description: | + Membership invitations filter. + + Filter field receives a base64 encoded JSON object to limit the search. + schema: + type: string + required: false + in: query + - $ref: ../components/parameters/query/pageQuery.yaml + - $ref: ../components/parameters/query/perPageQuery.yaml + responses: + '200': + description: Successfully retrieved namespace membership invitations list. + headers: + X-Total-Count: + description: Total number of membership invitations. + schema: + type: integer + minimum: 0 + content: + application/json: + schema: + type: array + items: + $ref: ../components/schemas/membershipInvitation.yaml + '401': + $ref: ../components/responses/401.yaml + '403': + $ref: ../components/responses/403.yaml + '404': + $ref: ../components/responses/404.yaml + '500': + $ref: ../components/responses/500.yaml diff --git a/openapi/spec/paths/api@namespaces@{tenant}@members@accept-invite.yaml b/openapi/spec/paths/api@namespaces@{tenant}@invitations@accept.yaml similarity index 61% rename from openapi/spec/paths/api@namespaces@{tenant}@members@accept-invite.yaml rename to openapi/spec/paths/api@namespaces@{tenant}@invitations@accept.yaml index 72bad69d4de..abb9c12a51d 100644 --- a/openapi/spec/paths/api@namespaces@{tenant}@members@accept-invite.yaml +++ b/openapi/spec/paths/api@namespaces@{tenant}@invitations@accept.yaml @@ -2,7 +2,7 @@ patch: operationId: acceptInvite summary: Accept a membership invite description: | - This route is intended to be accessed directly through the link sent in the invitation email. + Accepts a pending membership invitation for the authenticated user. The user must be logged into the account that was invited. tags: - cloud @@ -13,18 +13,6 @@ patch: - jwt: [] parameters: - $ref: ../components/parameters/path/namespaceTenantIDPath.yaml - requestBody: - content: - application/json: - schema: - type: object - properties: - sig: - description: The unique key included in the email link. - type: string - example: b25e93bc-22ac-4f02-901a-52af9f358a5d - required: - - sig responses: '200': description: Invitation successfully accepted diff --git a/openapi/spec/paths/api@namespaces@{tenant}@invitations@decline.yaml b/openapi/spec/paths/api@namespaces@{tenant}@invitations@decline.yaml new file mode 100644 index 00000000000..bbf7a524dda --- /dev/null +++ b/openapi/spec/paths/api@namespaces@{tenant}@invitations@decline.yaml @@ -0,0 +1,29 @@ +patch: + operationId: declineInvite + summary: Decline a membership invite + description: | + Declines a pending membership invitation for the authenticated user. + The user must be logged into the account that was invited. + The invitation status will be updated to "rejected". + tags: + - cloud + - members + - namespaces + - users + security: + - jwt: [] + parameters: + - $ref: ../components/parameters/path/namespaceTenantIDPath.yaml + responses: + '200': + description: Invitation successfully declined + '400': + $ref: ../components/responses/400.yaml + '401': + $ref: ../components/responses/401.yaml + '403': + $ref: ../components/responses/403.yaml + '404': + $ref: ../components/responses/404.yaml + '500': + $ref: ../components/responses/500.yaml diff --git a/openapi/spec/paths/api@namespaces@{tenant}@members@invites.yaml b/openapi/spec/paths/api@namespaces@{tenant}@invitations@links.yaml similarity index 100% rename from openapi/spec/paths/api@namespaces@{tenant}@members@invites.yaml rename to openapi/spec/paths/api@namespaces@{tenant}@invitations@links.yaml diff --git a/openapi/spec/paths/api@namespaces@{tenant}@invitations@{user-id}.yaml b/openapi/spec/paths/api@namespaces@{tenant}@invitations@{user-id}.yaml new file mode 100644 index 00000000000..f1ba34f343b --- /dev/null +++ b/openapi/spec/paths/api@namespaces@{tenant}@invitations@{user-id}.yaml @@ -0,0 +1,77 @@ +patch: + operationId: updateMembershipInvitation + summary: Update a pending membership invitation + description: | + Allows namespace administrators to update a pending membership invitation. + Currently supports updating the role assigned to the invitation. + The active user must have authority over the role being assigned. + tags: + - cloud + - members + - namespaces + security: + - jwt: [] + parameters: + - $ref: ../components/parameters/path/namespaceTenantIDPath.yaml + - name: user-id + description: The ID of the invited user + schema: + type: string + required: true + in: path + requestBody: + content: + application/json: + schema: + type: object + properties: + role: + $ref: ../components/schemas/namespaceMemberRole.yaml + responses: + '200': + description: Invitation successfully updated + '400': + $ref: ../components/responses/400.yaml + '401': + $ref: ../components/responses/401.yaml + '403': + $ref: ../components/responses/403.yaml + '404': + $ref: ../components/responses/404.yaml + '500': + $ref: ../components/responses/500.yaml + +delete: + operationId: cancelMembershipInvitation + summary: Cancel a pending membership invitation + description: | + Allows namespace administrators to cancel a pending membership invitation. + The invitation status will be updated to "cancelled". + The active user must have authority over the role of the invitation being cancelled. + tags: + - cloud + - members + - namespaces + security: + - jwt: [] + parameters: + - $ref: ../components/parameters/path/namespaceTenantIDPath.yaml + - name: user-id + description: The ID of the invited user + schema: + type: string + required: true + in: path + responses: + '200': + description: Invitation successfully cancelled + '400': + $ref: ../components/responses/400.yaml + '401': + $ref: ../components/responses/401.yaml + '403': + $ref: ../components/responses/403.yaml + '404': + $ref: ../components/responses/404.yaml + '500': + $ref: ../components/responses/500.yaml diff --git a/openapi/spec/paths/api@users@invitations.yaml b/openapi/spec/paths/api@users@invitations.yaml new file mode 100644 index 00000000000..62cbe7e7de2 --- /dev/null +++ b/openapi/spec/paths/api@users@invitations.yaml @@ -0,0 +1,43 @@ +get: + operationId: getMembershipInvitationList + summary: Get membership invitations for the authenticated user + description: | + Returns a paginated list of membership invitations for the authenticated user. + This endpoint allows users to view all namespace invitations they have received. + tags: + - cloud + - members + - namespaces + security: + - jwt: [] + parameters: + - name: filter + description: | + Membership invitations filter. + + Filter field receives a base64 encoded JSON object to limit the search. + schema: + type: string + required: false + in: query + - $ref: ../components/parameters/query/pageQuery.yaml + - $ref: ../components/parameters/query/perPageQuery.yaml + responses: + '200': + description: Successfully retrieved membership invitations list. + headers: + X-Total-Count: + description: Total number of membership invitations. + schema: + type: integer + minimum: 0 + content: + application/json: + schema: + type: array + items: + $ref: ../components/schemas/membershipInvitation.yaml + '401': + $ref: ../components/responses/401.yaml + '500': + $ref: ../components/responses/500.yaml diff --git a/pkg/models/member.go b/pkg/models/member.go index 758f8e991b2..77bea7908b0 100644 --- a/pkg/models/member.go +++ b/pkg/models/member.go @@ -6,22 +6,9 @@ import ( "github.com/shellhub-io/shellhub/pkg/api/authorizer" ) -type MemberStatus string - -const ( - MemberStatusPending MemberStatus = "pending" - MemberStatusAccepted MemberStatus = "accepted" -) - type Member struct { - ID string `json:"id,omitempty" bson:"id,omitempty"` - AddedAt time.Time `json:"added_at" bson:"added_at"` - - // ExpiresAt specifies the expiration date of the invite. This attribute is only applicable in *Cloud* instances, - // and it is ignored for members whose status is not 'pending'. - ExpiresAt time.Time `json:"expires_at" bson:"expires_at"` - - Email string `json:"email" bson:"email,omitempty" validate:"email"` - Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` - Status MemberStatus `json:"status" bson:"status"` + ID string `json:"id,omitempty" bson:"id,omitempty"` + AddedAt time.Time `json:"added_at" bson:"added_at"` + Email string `json:"email" bson:"email,omitempty" validate:"email"` + Role authorizer.Role `json:"role" bson:"role" validate:"required,oneof=administrator operator observer"` } diff --git a/pkg/models/membership-invitation.go b/pkg/models/membership-invitation.go new file mode 100644 index 00000000000..f5cdfb8e621 --- /dev/null +++ b/pkg/models/membership-invitation.go @@ -0,0 +1,44 @@ +package models + +import ( + "time" + + "github.com/shellhub-io/shellhub/pkg/api/authorizer" + "github.com/shellhub-io/shellhub/pkg/clock" +) + +type MembershipInvitationStatus string + +const ( + MembershipInvitationStatusPending MembershipInvitationStatus = "pending" + MembershipInvitationStatusAccepted MembershipInvitationStatus = "accepted" + MembershipInvitationStatusRejected MembershipInvitationStatus = "rejected" + MembershipInvitationStatusCancelled MembershipInvitationStatus = "cancelled" +) + +type MembershipInvitation struct { + ID string `json:"-" bson:"_id"` + TenantID string `json:"-" bson:"tenant_id"` + UserID string `json:"-" bson:"user_id"` + InvitedBy string `json:"invited_by" bson:"invited_by"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` + ExpiresAt *time.Time `json:"expires_at" bson:"expires_at"` + Status MembershipInvitationStatus `json:"status" bson:"status"` + StatusUpdatedAt time.Time `json:"status_updated_at" bson:"status_updated_at"` + Role authorizer.Role `json:"role" bson:"role"` + Invitations int `json:"-" bson:"invitations"` + + // NamespaceName isn't saved on the database + NamespaceName string `json:"-" bson:"namespace_name,omitempty"` + // UserEmail isn't saved on the database + UserEmail string `json:"-" bson:"user_email,omitempty"` +} + +func (m MembershipInvitation) IsExpired() bool { + return m.ExpiresAt != nil && m.ExpiresAt.Before(clock.Now()) +} + +func (m MembershipInvitation) IsPending() bool { + return m.Status == MembershipInvitationStatusPending +}