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 endpoint has been added in the gateway to redirect
acceptance requests to the cloud API, where the acceptance will be
processed.
  • Loading branch information
heiytor committed Sep 3, 2024
1 parent 8fe6393 commit 8ae058f
Show file tree
Hide file tree
Showing 8 changed files with 360 additions and 47 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
222 changes: 190 additions & 32 deletions api/services/namespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,10 +1142,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 @@ -1161,10 +1162,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 @@ -1189,10 +1191,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 @@ -1220,10 +1223,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 @@ -1257,10 +1261,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 @@ -1294,10 +1299,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 @@ -1335,10 +1341,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 @@ -1370,6 +1377,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 @@ -1383,10 +1394,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 @@ -1418,6 +1430,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 @@ -1464,6 +1480,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
27 changes: 27 additions & 0 deletions gateway/nginx/conf.d/shellhub.conf
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ server {
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;
Expand Down Expand Up @@ -131,6 +134,30 @@ server {
proxy_pass http://upstream_router;
}

location ~^/api/namespaces/[^/]+/members/accept-invite$ {
{{ set_upstream "cloud-api" 8080 }}

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_router;
}

location ~ ^/(install.sh|kickstart.sh)$ {
{{ set_upstream "api" 8080 }}

Expand Down
Loading

0 comments on commit 8ae058f

Please sign in to comment.