Skip to content

Commit

Permalink
feat(api): generate new user token upon exiting authenticated namespace
Browse files Browse the repository at this point in the history
Automatically generate a fresh token when users exit the authenticated
namespace.
  • Loading branch information
heiytor authored and gustavosbarreto committed Nov 13, 2024
1 parent 3063a3d commit a521f61
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 31 deletions.
10 changes: 7 additions & 3 deletions api/routes/nsadm.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,15 @@ func (h *Handler) LeaveNamespace(c gateway.Context) error {
return err
}

if err := h.service.LeaveNamespace(c.Ctx(), req); err != nil {
res, err := h.service.LeaveNamespace(c.Ctx(), req)
switch {
case err != nil:
return err
case res != nil:
return c.JSON(http.StatusOK, res)
default:
return c.NoContent(http.StatusOK)
}

return c.NoContent(http.StatusOK)
}

func (h *Handler) EditNamespaceMember(c gateway.Context) error {
Expand Down
4 changes: 2 additions & 2 deletions api/routes/nsadm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ func TestHandler_LeaveNamespace(t *testing.T) {
requiredMocks: func() {
svcMock.
On("LeaveNamespace", gomock.Anything, &requests.LeaveNamespace{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", AuthenticatedTenantID: "00000000-0000-4000-0000-000000000000"}).
Return(errors.New("error")).
Return(nil, errors.New("error")).
Once()
},
expected: http.StatusInternalServerError,
Expand All @@ -431,7 +431,7 @@ func TestHandler_LeaveNamespace(t *testing.T) {
requiredMocks: func() {
svcMock.
On("LeaveNamespace", gomock.Anything, &requests.LeaveNamespace{UserID: "000000000000000000000000", TenantID: "00000000-0000-4000-0000-000000000000", AuthenticatedTenantID: "00000000-0000-4000-0000-000000000000"}).
Return(nil).
Return(nil, nil).
Once()
},
expected: http.StatusOK,
Expand Down
39 changes: 26 additions & 13 deletions api/services/member.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type MemberService interface {
// LeaveNamespace allows an authenticated user to remove themselves from a namespace. Owners cannot leave a namespace.
// If the user attempts to leave the namespace they are authenticated to, their authentication token will be invalidated.
// Returns an error, if any.
LeaveNamespace(ctx context.Context, req *requests.LeaveNamespace) error
LeaveNamespace(ctx context.Context, req *requests.LeaveNamespace) (*models.UserAuthResponse, error)
}

func (s *service) AddNamespaceMember(ctx context.Context, req *requests.NamespaceAddMember) (*models.Namespace, error) {
Expand Down Expand Up @@ -232,30 +232,43 @@ func (s *service) RemoveNamespaceMember(ctx context.Context, req *requests.Names
return s.store.NamespaceGet(ctx, req.TenantID, s.store.Options().CountAcceptedDevices(), s.store.Options().EnrichMembersData())
}

func (s *service) LeaveNamespace(ctx context.Context, req *requests.LeaveNamespace) error {
func (s *service) LeaveNamespace(ctx context.Context, req *requests.LeaveNamespace) (*models.UserAuthResponse, error) {
ns, err := s.store.NamespaceGet(ctx, req.TenantID)
if err != nil {
return NewErrNamespaceNotFound(req.TenantID, err)
return nil, NewErrNamespaceNotFound(req.TenantID, err)
}

if m, ok := ns.FindMember(req.UserID); !ok || m.Role == authorizer.RoleOwner {
return NewErrAuthForbidden()
return nil, NewErrAuthForbidden()
}

if err := s.removeMember(ctx, ns, req.UserID); err != nil { //nolint:revive
return err
return nil, err
}

if req.TenantID == req.AuthenticatedTenantID {
if err := s.AuthUncacheToken(ctx, req.TenantID, req.UserID); err != nil {
log.WithError(err).
WithField("tenant_id", req.TenantID).
WithField("user_id", req.UserID).
Error("failed to uncache the token")
}
// If the user is attempting to leave a namespace other than the authenticated one,
// there is no need to generate a new token.
if req.TenantID != req.AuthenticatedTenantID {
return nil, nil
}

return nil
emptyString := "" // just to be used as a pointer
if err := s.store.UserUpdate(ctx, req.UserID, &models.UserChanges{PreferredNamespace: &emptyString}); err != nil {
log.WithError(err).
WithField("tenant_id", req.TenantID).
WithField("user_id", req.UserID).
Error("failed to reset user's preferred namespace")
}

if err := s.AuthUncacheToken(ctx, req.TenantID, req.UserID); err != nil {
log.WithError(err).
WithField("tenant_id", req.TenantID).
WithField("user_id", req.UserID).
Error("failed to uncache the token")
}

// TODO: make this method a util function
return s.CreateUserToken(ctx, &requests.CreateUserToken{UserID: req.UserID})
}

func (s *service) removeMember(ctx context.Context, ns *models.Namespace, userID string) error {
Expand Down
95 changes: 87 additions & 8 deletions api/services/member_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,7 @@ func TestRemoveNamespaceMember(t *testing.T) {

func TestService_LeaveNamespace(t *testing.T) {
type Expected struct {
res *models.UserAuthResponse
err error
}

Expand All @@ -1587,7 +1588,10 @@ func TestService_LeaveNamespace(t *testing.T) {
Return(nil, ErrNamespaceNotFound).
Once()
},
expected: Expected{err: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound)},
expected: Expected{
res: nil,
err: NewErrNamespaceNotFound("00000000-0000-4000-0000-000000000000", ErrNamespaceNotFound),
},
},
{
description: "fails when the user is not on the namespace",
Expand All @@ -1607,7 +1611,10 @@ func TestService_LeaveNamespace(t *testing.T) {
}, nil).
Once()
},
expected: Expected{err: NewErrAuthForbidden()},
expected: Expected{
res: nil,
err: NewErrAuthForbidden(),
},
},
{
description: "fails when the user is owner",
Expand All @@ -1632,7 +1639,10 @@ func TestService_LeaveNamespace(t *testing.T) {
}, nil).
Once()
},
expected: Expected{err: NewErrAuthForbidden()},
expected: Expected{
res: nil,
err: NewErrAuthForbidden(),
},
},
{
description: "fails when cannot remove the member",
Expand Down Expand Up @@ -1661,7 +1671,10 @@ func TestService_LeaveNamespace(t *testing.T) {
Return(errors.New("error")).
Once()
},
expected: Expected{errors.New("error")},
expected: Expected{
res: nil,
err: errors.New("error"),
},
},
{
description: "succeeds",
Expand Down Expand Up @@ -1690,7 +1703,10 @@ func TestService_LeaveNamespace(t *testing.T) {
Return(nil).
Once()
},
expected: Expected{err: nil},
expected: Expected{
res: nil,
err: nil,
},
},
{
description: "succeeds when TenantID is equal to AuthenticatedTenantID",
Expand Down Expand Up @@ -1718,12 +1734,69 @@ func TestService_LeaveNamespace(t *testing.T) {
On("NamespaceRemoveMember", ctx, "00000000-0000-4000-0000-000000000000", "000000000000000000000000").
Return(nil).
Once()
emptyString := ""
storeMock.
On("UserUpdate", ctx, "000000000000000000000000", &models.UserChanges{PreferredNamespace: &emptyString}).
Return(nil).
Once()
cacheMock.
On("Delete", ctx, "token_00000000-0000-4000-0000-000000000000000000000000000000000000").
Return(nil).
Once()

// NOTE: This test is a replica of TestService_CreateUserToken because this method
// internally calls it to create another token. Since this functionality is already tested,
// we are duplicating the test here to prevent failures. The important tests are all in the lines above.
storeMock.
On("UserGetByID", ctx, "000000000000000000000000", false).
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: "",
},
},
0,
nil,
).
Once()
storeMock.
On("NamespaceGetPreferred", ctx, "000000000000000000000000").
Return(nil, store.ErrNoDocuments).
Once()
clockMock := new(clockmock.Clock)
clock.DefaultBackend = clockMock
clockMock.On("Now").Return(now)
cacheMock.
On("Set", ctx, "token_000000000000000000000000", mock.Anything, time.Hour*72).
Return(nil).
Once()
},
expected: Expected{
res: &models.UserAuthResponse{
ID: "000000000000000000000000",
Name: "john doe",
User: "john_doe",
Email: "john.doe@test.com",
Tenant: "",
Role: "",
Token: "must ignore",
},
err: nil,
},
expected: Expected{err: nil},
},
}

Expand All @@ -1734,8 +1807,14 @@ func TestService_LeaveNamespace(t *testing.T) {
ctx := context.TODO()
tc.requiredMocks(ctx)

err := s.LeaveNamespace(ctx, tc.req)
assert.Equal(t, tc.expected, Expected{err})
res, err := s.LeaveNamespace(ctx, tc.req)
// Since the resulting token is not crucial for the assertion and
// difficult to mock, it is safe to ignore this field.
if res != nil {
res.Token = "must ignore"
}

assert.Equal(t, tc.expected, Expected{res, err})
})
}

Expand Down
22 changes: 17 additions & 5 deletions api/services/mocks/services.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a521f61

Please sign in to comment.