Skip to content

Commit

Permalink
feat(api, cloud): send a registration email to member if not found
Browse files Browse the repository at this point in the history
In cloud instances, when adding a user to a namespace, if the target
user does not exist, a "placeholder" with only the email address will be
created. This placeholder will have a status of `not-registered` and
will be added as a `pending` member in the namespace. The service will
send a registration email, and once the user completes the registration,
they can accept the invitation.

Users with the new status will be treated as if they did not exist at
login.
  • Loading branch information
heiytor committed Sep 5, 2024
1 parent 3938f9c commit b664f08
Show file tree
Hide file tree
Showing 5 changed files with 185 additions and 5 deletions.
7 changes: 6 additions & 1 deletion api/services/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,13 @@ func (s *service) AuthUser(ctx context.Context, req *requests.UserAuth, sourceIP
return nil, 0, "", NewErrAuthUnathorized(nil)
}

if user.Status != models.UserStatusConfirmed {
switch user.Status {
case models.UserStatusNotRegistered:
return nil, 0, "", NewErrAuthUnathorized(nil)
case models.UserStatusNotConfirmed:
return nil, 0, "", NewErrUserNotConfirmed(nil)
default:
break
}

// Checks whether the user is currently blocked from new login attempts
Expand Down
41 changes: 41 additions & 0 deletions api/services/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,47 @@ func TestAuthUser(t *testing.T) {
err: NewErrUserNotConfirmed(nil),
},
},
{
description: "fails when user is not registered",
sourceIP: "127.0.0.1",
req: &requests.UserAuth{
Identifier: "[email protected]",
Password: "secret",
},
requiredMocks: func() {
mock.
On("UserGetByEmail", ctx, "[email protected]").
Return(
&models.User{

ID: "65fdd16b5f62f93184ec8a39",
Status: models.UserStatusNotRegistered,
LastLogin: now,
MFA: models.UserMFA{
Enabled: false,
},
UserData: models.UserData{
Username: "john_doe",
Email: "[email protected]",
},
Password: models.UserPassword{
Hash: "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b",
},
Preferences: models.UserPreferences{
PreferredNamespace: "",
},
},
nil,
).
Once()
},
expected: Expected{
res: nil,
lockout: 0,
mfaToken: "",
err: NewErrAuthUnathorized(nil),
},
},
{
description: "fails when an account lockout occurs",
sourceIP: "127.0.0.1",
Expand Down
32 changes: 29 additions & 3 deletions api/services/namespace.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,19 @@ type NamespaceService interface {
// EditNamespace updates a namespace for the specified requests.NamespaceEdit#Tenant.
// It returns the namespace with the updated fields and an error, if any.
EditNamespace(ctx context.Context, req *requests.NamespaceEdit) (*models.Namespace, error)
// AddNamespaceMember adds a member to a namespace. The member's role cannot have more authority than the user who is
// adding the member; owners cannot be created. It returns the namespace and an error, if any.

// AddNamespaceMember adds a member to a namespace.
//
// The member's role cannot have more authority than the user who is adding the member; owners cannot be created.
//
// In cloud environments, the member has a [MemberStatusPending] 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 will
// be automatically accepted after registration. In community and enterprise environments, the status is set to
// [MemberStatusAccepted] without sending an email.
//
// It returns the namespace and an error, if any.
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.
UpdateNamespaceMember(ctx context.Context, req *requests.NamespaceUpdateMember) error
Expand Down Expand Up @@ -239,7 +249,23 @@ func (s *service) AddNamespaceMember(ctx context.Context, req *requests.Namespac

passiveUser, err := s.store.UserGetByEmail(ctx, strings.ToLower(req.MemberEmail))
if err != nil {
return nil, NewErrUserNotFound(req.MemberEmail, err)
// In cloud instances, if the target user does not exist, we need to create a new user
// with the specified email. This process returns an ID that can be used to identify
// the user once they complete their registration. The invite is automatically accepted
// when the user finishes the registration.
if !errors.Is(err, store.ErrNoDocuments) || !envs.IsCloud() {
return nil, NewErrUserNotFound(req.MemberEmail, err)
}

passiveUser = &models.User{
Status: models.UserStatusNotRegistered,
UserData: models.UserData{Email: strings.ToLower(req.MemberEmail)},
}

passiveUser.ID, err = s.store.UserCreate(ctx, passiveUser)
if err != nil {
return nil, err
}
}

member := &models.Member{
Expand Down
106 changes: 105 additions & 1 deletion api/services/namespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,7 @@ func TestAddNamespaceMember(t *testing.T) {
},
},
{
description: "fails when passive member was not found",
description: "fails when cannot retrieve the member",
req: &requests.NamespaceAddMember{
FowardedHost: "localhost",
UserID: "000000000000000000000000",
Expand Down Expand Up @@ -1338,6 +1338,52 @@ func TestAddNamespaceMember(t *testing.T) {
err: NewErrUserNotFound("[email protected]", errors.New("error")),
},
},
{
description: "fails when passive member was not found and is not cloud",
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(nil, store.ErrNoDocuments).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("false").
Once()
},
expected: Expected{
namespace: nil,
err: NewErrUserNotFound("[email protected]", store.ErrNoDocuments),
},
},
{
description: "fails when cannot add the member",
req: &requests.NamespaceAddMember{
Expand Down Expand Up @@ -1530,6 +1576,64 @@ func TestAddNamespaceMember(t *testing.T) {
err: errors.New("error"),
},
},
{
description: "succeeds to create the member when not found",
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"}, store.ErrNoDocuments).
Once()
envMock.
On("Get", "SHELLHUB_CLOUD").
Return("true").
Once()
storeMock.
On("UserCreate", ctx, &models.User{Status: models.UserStatusNotRegistered, UserData: models.UserData{Email: "[email protected]"}}).
Return("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{
Expand Down
4 changes: 4 additions & 0 deletions pkg/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
type UserStatus string

const (
// UserStatusNotRegistered applies to cloud-only instances. This status is assigned to a user who has been invited to a
// namespace but has not yet completed the registration process.
UserStatusNotRegistered UserStatus = "not-registered"

// UserStatusNotConfirmed applies to cloud-only instances. This status is assigned to a user who has registered
// but has not yet confirmed their email address.
UserStatusNotConfirmed UserStatus = "not-confirmed"
Expand Down

0 comments on commit b664f08

Please sign in to comment.