Skip to content

Commit

Permalink
feat(api): invite member
Browse files Browse the repository at this point in the history
Membership invitations in cloud instances will now send an invitation
email to the user instead of automatically accepting the invite.
Enterprise and community instances remain unchanged.

Additionally, a new location has been added in the gateway to redirect
acceptance requests to the cloud API.
  • Loading branch information
heiytor authored and luannmoreira committed Aug 27, 2024
1 parent 721b88b commit c8a5eac
Show file tree
Hide file tree
Showing 9 changed files with 385 additions and 56 deletions.
6 changes: 3 additions & 3 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
"github.com/shellhub-io/shellhub/api/store"
"github.com/shellhub-io/shellhub/api/store/mongo"
"github.com/shellhub-io/shellhub/api/store/mongo/options"
requests "github.com/shellhub-io/shellhub/pkg/api/internalclient"
"github.com/shellhub-io/shellhub/pkg/api/internalclient"
storecache "github.com/shellhub-io/shellhub/pkg/cache"
"github.com/shellhub-io/shellhub/pkg/geoip"
"github.com/shellhub-io/shellhub/pkg/worker/asynq"
Expand Down Expand Up @@ -143,7 +143,7 @@ func startSentry(dsn string) (*sentry.Client, error) {
func startServer(ctx context.Context, cfg *config, store store.Store, cache storecache.Cache) error {
log.Info("Starting API server")

requestClient, err := requests.NewClient()
apiClient, err := internalclient.NewClient(internalclient.WithAsynqWorker(cfg.RedisURI))
if err != nil {
log.WithError(err).
Fatal("failed to create the internalclient")
Expand All @@ -162,7 +162,7 @@ func startServer(ctx context.Context, cfg *config, store store.Store, cache stor
servicesOptions = append(servicesOptions, services.WithLocator(locator))
}

service := services.NewService(store, nil, nil, cache, requestClient, servicesOptions...)
service := services.NewService(store, nil, nil, cache, apiClient, servicesOptions...)

routerOptions := []routes.Option{}

Expand Down
10 changes: 9 additions & 1 deletion api/services/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,21 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac
return nil, NewErrUserNotFound(req.MemberEmail, err)
}

// Currently, the member's status is always "accepted".
member := &models.Member{
ID: passiveUser.ID,
AddedAt: clock.Now(),
Role: req.MemberRole,
Status: models.MemberStatusAccepted,
}

// In cloud instances, the member must accept the invite before enter in the namespace.
if envs.IsCloud() {
member.Status = models.MemberStatusPending
if err := s.client.InviteMember(ctx, req.TenantID, member.ID, req.FowardedHost); err != nil {
return nil, err
}
}

if err := s.store.NamespaceAddMember(ctx, req.TenantID, member); err != nil {
switch {
case errors.Is(err, mongo.ErrNamespaceDuplicatedMember):
Expand Down
230 changes: 198 additions & 32 deletions api/services/namespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"strconv"
"strings"
"testing"
"time"

"github.com/shellhub-io/shellhub/api/store"
"github.com/shellhub-io/shellhub/api/store/mocks"
"github.com/shellhub-io/shellhub/pkg/api/authorizer"
"github.com/shellhub-io/shellhub/pkg/api/query"
"github.com/shellhub-io/shellhub/pkg/api/requests"
storecache "github.com/shellhub-io/shellhub/pkg/cache"
"github.com/shellhub-io/shellhub/pkg/clock"
clockmocks "github.com/shellhub-io/shellhub/pkg/clock/mocks"
"github.com/shellhub-io/shellhub/pkg/models"
"github.com/shellhub-io/shellhub/pkg/uuid"
uuid_mocks "github.com/shellhub-io/shellhub/pkg/uuid/mocks"
Expand Down Expand Up @@ -1017,6 +1020,11 @@ func TestAddNamespaceMember(t *testing.T) {
}

storeMock := new(mocks.Store)
clockmock := new(clockmocks.Clock)
clock.DefaultBackend = clockmock

now := time.Now()
clockmock.On("Now").Return(now)

cases := []struct {
description string
Expand All @@ -1027,10 +1035,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when the namespace was not found",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand All @@ -1046,10 +1055,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when the active member was not found",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand All @@ -1074,10 +1084,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when the active member is not on the namespace",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1105,10 +1116,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when the passive role's is owner",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleOwner,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleOwner,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1142,10 +1154,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when the active member's role cannot act over passive member's role",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleAdministrator,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleAdministrator,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1179,10 +1192,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when passive member was not found",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1220,10 +1234,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "fails when cannot add the member",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1255,6 +1270,10 @@ func TestAddNamespaceMember(t *testing.T) {
UserData: models.UserData{Username: "john_doe"},
}, nil).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("false").
Once()
storeMock.
On("NamespaceAddMember", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now}).
Return(errors.New("error")).
Expand All @@ -1268,10 +1287,11 @@ func TestAddNamespaceMember(t *testing.T) {
{
description: "succeeds",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
Expand Down Expand Up @@ -1303,6 +1323,10 @@ func TestAddNamespaceMember(t *testing.T) {
UserData: models.UserData{Username: "john_doe"},
}, nil).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("false").
Once()
storeMock.
On("NamespaceAddMember", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleObserver, Status: models.MemberStatusAccepted, AddedAt: now}).
Return(nil).
Expand Down Expand Up @@ -1349,6 +1373,148 @@ func TestAddNamespaceMember(t *testing.T) {
err: nil,
},
},
{
description: "fails when instance is cloud and cannot send the invite",
req: &requests.NamespaceAddMember{
FowardedHost: "localhost",
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
},
requiredMocks: func(ctx context.Context) {
storeMock.
On("NamespaceGet", ctx, "00000000-0000-4000-0000-000000000000", true).
Return(&models.Namespace{
TenantID: "00000000-0000-4000-0000-000000000000",
Name: "namespace",
Owner: "000000000000000000000000",
Members: []models.Member{
{
ID: "000000000000000000000000",
Role: authorizer.RoleOwner,
Status: models.MemberStatusAccepted,
},
},
}, nil).
Once()
storeMock.
On("UserGetByID", ctx, "000000000000000000000000", false).
Return(&models.User{
ID: "000000000000000000000000",
UserData: models.UserData{Username: "jane_doe"},
}, 0, nil).
Once()
storeMock.
On("UserGetByEmail", ctx, "[email protected]").
Return(&models.User{ID: "000000000000000000000001"}, nil).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("true").
Once()
clientMock.
On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001", "localhost").
Return(errors.New("error")).
Once()
},
expected: Expected{
namespace: nil,
err: errors.New("error"),
},
},
{
description: "succeeds when the instance is cloud",
req: &requests.NamespaceAddMember{
UserID: "000000000000000000000000",
TenantID: "00000000-0000-4000-0000-000000000000",
MemberEmail: "[email protected]",
MemberRole: authorizer.RoleObserver,
FowardedHost: "localhost",
},
requiredMocks: func(ctx context.Context) {
storeMock.
On("NamespaceGet", ctx, "00000000-0000-4000-0000-000000000000", true).
Return(&models.Namespace{
TenantID: "00000000-0000-4000-0000-000000000000",
Name: "namespace",
Owner: "000000000000000000000000",
Members: []models.Member{
{
ID: "000000000000000000000000",
Role: authorizer.RoleOwner,
Status: models.MemberStatusAccepted,
},
},
}, nil).
Once()
storeMock.
On("UserGetByID", ctx, "000000000000000000000000", false).
Return(&models.User{
ID: "000000000000000000000000",
UserData: models.UserData{Username: "jane_doe"},
}, 0, nil).
Once()
storeMock.
On("UserGetByEmail", ctx, "[email protected]").
Return(&models.User{
ID: "000000000000000000000001",
}, nil).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("true").
Once()
clientMock.
On("InviteMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000001", "localhost").
Return(nil).
Once()
storeMock.
On("NamespaceAddMember", ctx, "00000000-0000-4000-0000-000000000000", &models.Member{ID: "000000000000000000000001", Role: authorizer.RoleObserver, Status: models.MemberStatusPending, AddedAt: now}).
Return(nil).
Once()
storeMock.
On("NamespaceGet", ctx, "00000000-0000-4000-0000-000000000000", true).
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,
},
},
}, nil).
Once()
},
expected: Expected{
namespace: &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,
},
},
},
err: nil,
},
},
}

s := NewService(store.Store(storeMock), privateKey, publicKey, storecache.NewNullCache(), clientMock)
Expand Down
Loading

0 comments on commit c8a5eac

Please sign in to comment.