diff --git a/appvalidate/mock.go b/appvalidate/mock.go new file mode 100644 index 0000000000..3a1fa463ab --- /dev/null +++ b/appvalidate/mock.go @@ -0,0 +1,167 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/appvalidate (interfaces: Repository,ChallengeGenerator) +// +// Generated by this command: +// +// mockgen -build_flags=--mod=mod -destination=./mock.go -package=appvalidate github.com/tidepool-org/platform/appvalidate Repository,ChallengeGenerator +// + +// Package appvalidate is a generated GoMock package. +package appvalidate + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder + isgomock struct{} +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockRepository) Get(ctx context.Context, f Filter) (*AppValidation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, f) + ret0, _ := ret[0].(*AppValidation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockRepositoryMockRecorder) Get(ctx, f any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRepository)(nil).Get), ctx, f) +} + +// GetAttestationChallenge mocks base method. +func (m *MockRepository) GetAttestationChallenge(ctx context.Context, f Filter) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAttestationChallenge", ctx, f) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAttestationChallenge indicates an expected call of GetAttestationChallenge. +func (mr *MockRepositoryMockRecorder) GetAttestationChallenge(ctx, f any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttestationChallenge", reflect.TypeOf((*MockRepository)(nil).GetAttestationChallenge), ctx, f) +} + +// IsVerified mocks base method. +func (m *MockRepository) IsVerified(ctx context.Context, f Filter) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsVerified", ctx, f) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsVerified indicates an expected call of IsVerified. +func (mr *MockRepositoryMockRecorder) IsVerified(ctx, f any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsVerified", reflect.TypeOf((*MockRepository)(nil).IsVerified), ctx, f) +} + +// UpdateAssertion mocks base method. +func (m *MockRepository) UpdateAssertion(ctx context.Context, f Filter, u AssertionUpdate) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAssertion", ctx, f, u) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAssertion indicates an expected call of UpdateAssertion. +func (mr *MockRepositoryMockRecorder) UpdateAssertion(ctx, f, u any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAssertion", reflect.TypeOf((*MockRepository)(nil).UpdateAssertion), ctx, f, u) +} + +// UpdateAttestation mocks base method. +func (m *MockRepository) UpdateAttestation(ctx context.Context, f Filter, u AttestationUpdate) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAttestation", ctx, f, u) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAttestation indicates an expected call of UpdateAttestation. +func (mr *MockRepositoryMockRecorder) UpdateAttestation(ctx, f, u any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAttestation", reflect.TypeOf((*MockRepository)(nil).UpdateAttestation), ctx, f, u) +} + +// Upsert mocks base method. +func (m *MockRepository) Upsert(ctx context.Context, v *AppValidation) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upsert", ctx, v) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upsert indicates an expected call of Upsert. +func (mr *MockRepositoryMockRecorder) Upsert(ctx, v any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockRepository)(nil).Upsert), ctx, v) +} + +// MockChallengeGenerator is a mock of ChallengeGenerator interface. +type MockChallengeGenerator struct { + ctrl *gomock.Controller + recorder *MockChallengeGeneratorMockRecorder + isgomock struct{} +} + +// MockChallengeGeneratorMockRecorder is the mock recorder for MockChallengeGenerator. +type MockChallengeGeneratorMockRecorder struct { + mock *MockChallengeGenerator +} + +// NewMockChallengeGenerator creates a new mock instance. +func NewMockChallengeGenerator(ctrl *gomock.Controller) *MockChallengeGenerator { + mock := &MockChallengeGenerator{ctrl: ctrl} + mock.recorder = &MockChallengeGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockChallengeGenerator) EXPECT() *MockChallengeGeneratorMockRecorder { + return m.recorder +} + +// GenerateChallenge mocks base method. +func (m *MockChallengeGenerator) GenerateChallenge(size int) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateChallenge", size) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateChallenge indicates an expected call of GenerateChallenge. +func (mr *MockChallengeGeneratorMockRecorder) GenerateChallenge(size any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateChallenge", reflect.TypeOf((*MockChallengeGenerator)(nil).GenerateChallenge), size) +} diff --git a/auth/service/api/v1/appvalidate_test.go b/auth/service/api/v1/appvalidate_test.go index 6dee6b5033..c1956e034a 100644 --- a/auth/service/api/v1/appvalidate_test.go +++ b/auth/service/api/v1/appvalidate_test.go @@ -44,13 +44,13 @@ var _ = Describe("App Validation", func() { challenge := "challenge" serverSessionToken := "serverToken" - unattestedUser := user{ + unattestedUser := testUser{ UserID: "unattested", SessionToken: "unattestedToken", Details: request.NewAuthDetails(request.MethodSessionToken, "unattested", "unattestedToken"), AttestationVerified: false, } - attestedUser := user{ + attestedUser := testUser{ UserID: "attested", SessionToken: "attestedToken", Details: request.NewAuthDetails(request.MethodSessionToken, "attested", "attestedToken"), @@ -58,7 +58,7 @@ var _ = Describe("App Validation", func() { AttestationVerified: false, AttestationChallenge: challenge, } - attestedUnverifiedUser := user{ + attestedUnverifiedUser := testUser{ UserID: "attestedUnverified", SessionToken: "attestedUnverifiedToken", Details: request.NewAuthDetails(request.MethodSessionToken, "attestedUnverified", "attestedUnverified"), @@ -66,7 +66,7 @@ var _ = Describe("App Validation", func() { AttestationVerified: false, AttestationChallenge: challenge, } - attestedVerifiedUser := user{ + attestedVerifiedUser := testUser{ UserID: "attestedVerified", SessionToken: "attestedVerifiedToken", Details: request.NewAuthDetails(request.MethodSessionToken, "attestedVerified", "attestedVerifiedToken"), @@ -75,7 +75,7 @@ var _ = Describe("App Validation", func() { AttestationChallenge: challenge, AssertionChallenge: challenge, } - users := []user{ + users := []testUser{ unattestedUser, attestedUser, attestedVerifiedUser, @@ -334,7 +334,7 @@ var _ = Describe("App Validation", func() { }) // user is a helper user that contains relevant user information for tests. -type user struct { +type testUser struct { UserID string SessionToken string Details request.AuthDetails diff --git a/auth/service/api/v1/permission.go b/auth/service/api/v1/permission.go new file mode 100644 index 0000000000..cb0947cc06 --- /dev/null +++ b/auth/service/api/v1/permission.go @@ -0,0 +1,84 @@ +package v1 + +import ( + "net/http" + + "github.com/ant0ine/go-json-rest/rest" + + "github.com/tidepool-org/platform/request" +) + +// requireCustodian aborts with an error if the user associated w/ the +// request doesn't have custodian access to the user with the id defined in the +// url param targetParamUserID. +// +// This mimics the logic of amoeba's requireCustodian access. This means a +// user has access to the target user if any of the following is true: +// - The is a service call (AuthDetails.IsService() == true) +// - The requester and target are the same - AuthDetails.UserID == targetParamUserID +// - The requester has explicit permissions to access targetParamUserID +func (r *Router) requireCustodian(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + return func(res rest.ResponseWriter, req *rest.Request) { + if handlerFunc != nil && res != nil && req != nil { + targetUserID := req.PathParam(targetParamUserID) + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + if details == nil { + request.MustNewResponder(res, req).Error(http.StatusUnauthorized, request.ErrorUnauthenticated()) + return + } + if details.IsService() || details.UserID() == targetUserID { + handlerFunc(res, req) + return + } + hasPerms, err := r.PermissionsClient().HasCustodianPermissions(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } +} + +// requireMembership proceeds if the user with the id specified in the URL +// paramter targetParamUserID has some association with the user in the current +// request - the "requester". This mimics amoeba's requireMembership function. +// +// This proceeds if any of the following are true: +// - The is a service call (AuthDetails.IsService() == true) +// - The requester and target are the same - AuthDetails.UserID == targetParamUserID +// - The requester has any permissions to targetParamUserID OR targetParamUserID has permissions to the requester. +func (r *Router) requireMembership(targetParamUserID string, handlerFunc rest.HandlerFunc) rest.HandlerFunc { + return func(res rest.ResponseWriter, req *rest.Request) { + if handlerFunc != nil && res != nil && req != nil { + targetUserID := req.PathParam(targetParamUserID) + responder := request.MustNewResponder(res, req) + ctx := req.Context() + details := request.GetAuthDetails(ctx) + if details == nil { + request.MustNewResponder(res, req).Error(http.StatusUnauthorized, request.ErrorUnauthenticated()) + return + } + if details.IsService() || details.UserID() == targetUserID { + handlerFunc(res, req) + return + } + hasPerms, err := r.PermissionsClient().UsersHaveSharingRelationship(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } +} diff --git a/auth/service/api/v1/profile.go b/auth/service/api/v1/profile.go new file mode 100644 index 0000000000..756b98b0d9 --- /dev/null +++ b/auth/service/api/v1/profile.go @@ -0,0 +1,251 @@ +package v1 + +import ( + "context" + stdErrs "errors" + "maps" + "net/http" + "sync" + + "github.com/ant0ine/go-json-rest/rest" + "golang.org/x/sync/errgroup" + + "github.com/tidepool-org/platform/permission" + "github.com/tidepool-org/platform/request" + "github.com/tidepool-org/platform/user" +) + +type trustPermissions struct { + TrustorPermissions *permission.Permission + TrusteePermissions *permission.Permission +} + +func (r *Router) ProfileRoutes() []*rest.Route { + return []*rest.Route{ + rest.Get("/v1/users/:userId/profile", r.requireMembership("userId", r.GetProfile)), + + rest.Get("/users/:userId/users", r.requireCustodian("userId", r.GetUsersWithProfiles)), + rest.Get("/metadata/users/:userId/users", r.requireCustodian("userId", r.GetUsersWithProfiles)), + + rest.Get("/v1/users/legacy/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), + rest.Get("/metadata/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), + + rest.Put("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + rest.Post("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + + rest.Put("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + rest.Put("/metadata/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + + rest.Post("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + rest.Post("/metadata/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + + rest.Delete("/v1/users/:userId/profile", r.requireCustodian("userId", r.DeleteProfile)), + rest.Delete("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.DeleteProfile)), + } +} + +func (r *Router) getProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { + profile, err := r.ProfileAccessor().FindLegacyUserProfile(ctx, userID) + if err != nil { + return nil, err + } + if profile == nil { + return nil, user.ErrUserProfileNotFound + } + return profile, nil +} + +func (r *Router) getSanitizedProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { + profile, err := r.getProfile(ctx, userID) + if err != nil { + return profile, err + } + details := request.GetAuthDetails(ctx) + if details == nil { + return nil, request.ErrorUnauthenticated() + } + if details.IsService() || details.UserID() == userID { + return profile, nil + } + trustorPerms, err := r.PermissionsClient().GetUserPermissions(ctx, details.UserID(), userID) + if err != nil { + return nil, err + } + if len(trustorPerms) == 0 { + profile.Sanitize() + } + return profile, nil +} + +// GetProfile returns the user's profile in the new, non seagull, format +func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + profile, err := r.getSanitizedProfile(ctx, userID) + if err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + + responder.Data(http.StatusOK, profile.ToUserProfile()) +} + +func (r *Router) GetUsersWithProfiles(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + targetUserID := req.PathParam("userId") + targetUser, err := r.UserAccessor().Get(ctx, targetUserID) + if err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + if targetUser == nil { + r.handleUserOrProfileErr(responder, user.ErrUserNotFound) + return + } + + mergedUserPerms := map[string]*trustPermissions{} + trustorPerms, err := r.PermissionsClient().PermissionsGrantedToUser(ctx, targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + for userID, perms := range trustorPerms { + if userID == targetUserID { + // Don't include own user in result + continue + } + + clone := maps.Clone(perms) + mergedUserPerms[userID] = &trustPermissions{ + TrustorPermissions: &clone, + } + } + + trusteePerms, err := r.PermissionsClient().PermissionsGrantedByUser(ctx, targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + for userID, perms := range trusteePerms { + if userID == targetUserID { + // Don't include own user in result + continue + } + + if _, ok := mergedUserPerms[userID]; !ok { + mergedUserPerms[userID] = &trustPermissions{} + } + clone := maps.Clone(perms) + mergedUserPerms[userID].TrusteePermissions = &clone + } + + lock := &sync.Mutex{} + results := user.TrustUserArray{} + group, ctx := errgroup.WithContext(ctx) + group.SetLimit(20) // do up to 20 concurrent requests like seagull did + for userID, trustPerms := range mergedUserPerms { + group.Go(func() error { + sharedUser, err := r.UserAccessor().Get(ctx, userID) + if stdErrs.Is(err, user.ErrUserNotFound) || sharedUser == nil { + // According to seagull code, "It's possible for a user profile to be deleted before the sharing permissions", so we can ignore if user or profile not found. + return nil + } + if err != nil { + return err + } + seagullProfile, err := r.getProfile(ctx, userID) + if stdErrs.Is(err, user.ErrUserProfileNotFound) || seagullProfile == nil { + return nil + } + if err != nil { + return err + } + profile := seagullProfile.ToUserProfile() + trustUser := &user.TrustUser{ + User: *sharedUser, + TrustPermissions: user.TrustPermissions{ + TrusteePermissions: trustPerms.TrusteePermissions, + TrustorPermissions: trustPerms.TrustorPermissions, + }, + } + trustUser.Profile = profile + lock.Lock() + results = append(results, trustUser) + lock.Unlock() + return nil + }) + } + if err := group.Wait(); err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + + // type TrustUserArray implements Sanitize to hide any properties for non service requests + responder.Data(http.StatusOK, results) +} + +// GetLegacyProfile returns user profiles in the legacy seagull format. +func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + profile, err := r.getSanitizedProfile(ctx, userID) + if err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + + responder.Data(http.StatusOK, profile) +} + +func (r *Router) UpdateLegacyProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + + profile := &user.LegacyUserProfile{} + if err := request.DecodeRequestBody(req.Request, profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if err := r.ProfileAccessor().UpdateLegacyUserProfile(ctx, userID, profile); err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + responder.Data(http.StatusOK, profile) +} + +func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + + profile := &user.Profile{} + if err := request.DecodeRequestBody(req.Request, profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if err := r.ProfileAccessor().UpdateUserProfile(ctx, userID, profile); err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + responder.Data(http.StatusOK, profile) +} + +func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + responder.Empty(http.StatusNotImplemented) +} + +func (r *Router) handleUserOrProfileErr(responder *request.Responder, err error) { + switch { + case stdErrs.Is(err, user.ErrUserNotFound), stdErrs.Is(err, user.ErrUserProfileNotFound): + // Many of the seagull clients don't treat 404 as an error so return 404 as is + responder.Empty(http.StatusNotFound) + return + default: + responder.InternalServerError(err) + } +} diff --git a/auth/service/api/v1/router.go b/auth/service/api/v1/router.go index e62e7b2e40..3e2296ace2 100644 --- a/auth/service/api/v1/router.go +++ b/auth/service/api/v1/router.go @@ -29,6 +29,7 @@ func (r *Router) Routes() []*rest.Route { r.DeviceCheckRoutes(), r.DeviceTokensRoutes(), r.AppValidateRoutes(), + r.ProfileRoutes(), } acc := make([]*rest.Route, 0) for _, r := range routes { diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index 0d7b528ae5..2065efddbd 100644 --- a/auth/service/api/v1/router_test.go +++ b/auth/service/api/v1/router_test.go @@ -1,20 +1,45 @@ package v1_test import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + gomock "go.uber.org/mock/gomock" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/ant0ine/go-json-rest/rest" + authServiceApiV1 "github.com/tidepool-org/platform/auth/service/api/v1" serviceTest "github.com/tidepool-org/platform/auth/service/test" + authTest "github.com/tidepool-org/platform/auth/test" "github.com/tidepool-org/platform/errors" errorsTest "github.com/tidepool-org/platform/errors/test" + "github.com/tidepool-org/platform/log" + logTest "github.com/tidepool-org/platform/log/test" + "github.com/tidepool-org/platform/permission" + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/request" + testRest "github.com/tidepool-org/platform/test/rest" + "github.com/tidepool-org/platform/user" + userTest "github.com/tidepool-org/platform/user/test" ) var _ = Describe("Router", func() { + var ctrl *gomock.Controller var svc *serviceTest.Service + var userAccessor *user.MockUserAccessor + var profileAccessor *user.MockProfileAccessor + var permsClient *permission.MockClient BeforeEach(func() { - svc = serviceTest.NewService() + ctrl = gomock.NewController(GinkgoT()) + svc, userAccessor, profileAccessor, permsClient = serviceTest.NewMockedService(ctrl) }) Context("NewRouter", func() { @@ -45,6 +70,983 @@ var _ = Describe("Router", func() { It("returns the expected routes", func() { Expect(rtr.Routes()).ToNot(BeEmpty()) }) + + Context("Profile", func() { + var res *testRest.ResponseWriter + var req *rest.Request + var ctx context.Context + var handlerFunc rest.HandlerFunc + var userID string + var details request.AuthDetails + var userProfile *user.Profile + var userRoles []string + var userDetails *user.User + + JustBeforeEach(func() { + app, err := rest.MakeRouter(rtr.Routes()...) + Expect(err).ToNot(HaveOccurred()) + Expect(app).ToNot(BeNil()) + handlerFunc = app.AppFunc() + }) + + BeforeEach(func() { + userID = userTest.RandomUserID() + res = testRest.NewResponseWriter() + res.HeaderOutput = &http.Header{} + req = testRest.NewRequest() + ctx = log.NewContextWithLogger(req.Context(), logTest.NewLogger()) + req.Request = req.WithContext(ctx) + + userProfile = &user.Profile{ + FullName: "Some User Profile", + Birthday: "2001-02-03", + DiagnosisDate: "2002-03-04", + About: "About me", + MRN: "11223344", + } + userDetails = &user.User{ + UserID: pointer.FromString(userID), + Username: pointer.FromString("dev@tidepool.org"), + } + userRoles = []string{user.RolePatient} + + userAccessor.EXPECT(). + Get(gomock.Any(), userID). + Return(userDetails, nil).AnyTimes() + }) + + Context("Legacy Profiles", func() { + Context("GetProfile", func() { + BeforeEach(func() { + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/v1/users/legacy/%s/profile", userID) + }) + BeforeEach(func() { + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + + It("it succeeds if the profile exists", func() { + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), userID). + Return(userProfile.ToLegacyProfile(userRoles), nil) + + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile.ToLegacyProfile(userRoles))).To(MatchJSON(res.WriteInputs[0])) + }) + + It("it includes the clinician object if user is a clinic", func() { + userProfile = &user.Profile{ + FullName: "Some Clinician", + } + userRoles = []string{user.RoleClinic} + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), userID). + Return(userProfile.ToLegacyProfile(userRoles), nil) + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile.ToLegacyProfile(userRoles))).To(MatchJSON(res.WriteInputs[0])) + Expect(userProfile.ToLegacyProfile(userRoles).Clinic).NotTo(BeNil()) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + }) + + It("retrieves user's own profile", func() { + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), userID). + Return(userProfile.ToLegacyProfile(userRoles), nil) + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile.ToLegacyProfile(userRoles))).To(MatchJSON(res.WriteInputs[0])) + }) + + Context("other persons profile", func() { + var otherPersonID string + var otherProfile *user.LegacyUserProfile + var sanitizedOtherProfile *user.LegacyUserProfile + var otherDetails *user.User + BeforeEach(func() { + otherPersonID = userTest.RandomUserID() + req.URL.Path = fmt.Sprintf("/v1/users/legacy/%s/profile", otherPersonID) + otherProfile = &user.LegacyUserProfile{ + FullName: "Someone Else's Profile", + Patient: &user.LegacyPatientProfile{ + Birthday: "2002-03-04", + DiagnosisDate: "2003-04-05", + About: "Not about me", + MRN: "11223346", + }, + } + sanitizedOtherProfile = &user.LegacyUserProfile{ + FullName: "Someone Else's Profile", + Patient: &user.LegacyPatientProfile{}, + } + otherDetails = &user.User{ + UserID: pointer.FromString(otherPersonID), + Username: pointer.FromString("dev+other@tidepool.org"), + } + }) + It("fails to retrieve another person's profile if user does not have access", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + Context("with trustor permissions", func() { + It("retrieves another person's profile if user has access", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + GetUserPermissions(gomock.Any(), userID, otherPersonID). + Return(permission.Permissions{ + permission.Read: permission.Permission{}, + }, nil).AnyTimes() + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile, nil).AnyTimes() + userAccessor.EXPECT(). + Get(gomock.Any(), otherPersonID). + Return(otherDetails, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(otherProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + Context("without trustor permissions", func() { + It("retrieves another person's sanitized profile if user does not have trustor permissions", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + GetUserPermissions(gomock.Any(), userID, otherPersonID). + Return(nil, nil).AnyTimes() + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile, nil).AnyTimes() + userAccessor.EXPECT(). + Get(gomock.Any(), otherPersonID). + Return(otherDetails, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(sanitizedOtherProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + }) + }) + }) + + Context("UpdateLegacyProfile", func() { + var updatedProfile *user.LegacyUserProfile + BeforeEach(func() { + req.Method = http.MethodPost + req.URL.Path = fmt.Sprintf("/v1/users/legacy/%s/profile", userID) + + updatedProfile = &user.LegacyUserProfile{ + FullName: "Updated User Profile", + Patient: &user.LegacyPatientProfile{ + Birthday: "2000-01-02", + DiagnosisDate: "2001-02-03", + About: "Updated info", + MRN: "11223345", + }, + } + bites, err := json.Marshal(updatedProfile) + + Expect(err).ToNot(HaveOccurred()) + req.Body = io.NopCloser(bytes.NewReader(bites)) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + profileAccessor.EXPECT(). + UpdateLegacyUserProfile(gomock.Any(), userID, gomock.Any()). + Return(nil) + }) + + It("succeeds", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + }) + + It("successfully updates own profile", func() { + profileAccessor.EXPECT(). + UpdateLegacyUserProfile(gomock.Any(), userID, gomock.Any()). + Return(nil) + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + + It("fails to update another person's profile that the user does not have custodian access to", func() { + otherPersonID := userTest.RandomUserID() + req.URL.Path = fmt.Sprintf("/v1/users/legacy/%s/profile", otherPersonID) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), userID, gomock.Not(userID)). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + }) + }) + }) + + Context("Non-legacy Profiles", func() { + Context("GetProfile", func() { + BeforeEach(func() { + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", userID) + }) + BeforeEach(func() { + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + + It("it succeeds if the profile exists", func() { + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), userID). + Return(userProfile.ToLegacyProfile(userRoles), nil) + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + }) + + It("retrieves user's own profile", func() { + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), userID). + Return(userProfile.ToLegacyProfile(userRoles), nil) + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + + Context("other persons profile", func() { + var otherPersonID string + var otherProfile *user.Profile + var sanitizedOtherProfile *user.Profile + var otherRoles []string + var otherDetails *user.User + BeforeEach(func() { + otherPersonID = userTest.RandomUserID() + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", otherPersonID) + otherProfile = &user.Profile{ + FullName: "Someone Else's Profile", + Birthday: "2002-03-04", + DiagnosisDate: "2003-04-05", + About: "Not about me", + MRN: "11223346", + } + sanitizedOtherProfile = &user.Profile{ + FullName: "Someone Else's Profile", + } + otherDetails = &user.User{ + UserID: pointer.FromString(otherPersonID), + Username: pointer.FromString("dev+other@tidepool.org"), + } + otherRoles = []string{user.RolePatient} + }) + It("fails to retrieve another person's profile if user does not have access", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + Context("with trustor permissions", func() { + It("retrieves another person's profile if user has access", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + GetUserPermissions(gomock.Any(), userID, otherPersonID). + Return(permission.Permissions{ + permission.Read: permission.Permission{}, + }, nil).AnyTimes() + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile.ToLegacyProfile(otherRoles), nil).AnyTimes() + userAccessor.EXPECT(). + Get(gomock.Any(), otherPersonID). + Return(otherDetails, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(otherProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + Context("without trustor permissions", func() { + It("retrieves another person's profile if user has access", func() { + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + GetUserPermissions(gomock.Any(), userID, otherPersonID). + Return(nil, nil).AnyTimes() + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile.ToLegacyProfile(otherRoles), nil).AnyTimes() + userAccessor.EXPECT(). + Get(gomock.Any(), otherPersonID). + Return(otherDetails, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(sanitizedOtherProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + }) + }) + }) + + Context("UpdateProfile", func() { + var updatedProfile *user.Profile + BeforeEach(func() { + req.Method = http.MethodPost + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", userID) + + updatedProfile = &user.Profile{ + FullName: "Updated User Profile", + Birthday: "2000-01-02", + DiagnosisDate: "2001-02-03", + About: "Updated info", + MRN: "11223345", + } + + bites, err := json.Marshal(updatedProfile) + + Expect(err).ToNot(HaveOccurred()) + req.Body = io.NopCloser(bytes.NewReader(bites)) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + UsersHaveSharingRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + + profileAccessor.EXPECT(). + UpdateUserProfile(gomock.Any(), userID, updatedProfile). + Return(nil).AnyTimes() + }) + + It("succeeds", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + profileAccessor.EXPECT(). + UpdateUserProfile(gomock.Any(), userID, updatedProfile). + Return(nil).AnyTimes() + }) + + It("successfully updates own profile", func() { + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(updatedProfile)).To(MatchJSON(res.WriteInputs[0])) + }) + It("fails to update another person's profile that the user does not have custodian access to", func() { + otherPersonID := userTest.RandomUserID() + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", otherPersonID) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), userID, gomock.Not(userID)). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + }) + }) + }) + }) + + Context("users/:userId/users", func() { + var res *testRest.ResponseWriter + var req *rest.Request + var ctx context.Context + var handlerFunc rest.HandlerFunc + var userID string + var details request.AuthDetails + + JustBeforeEach(func() { + app, err := rest.MakeRouter(rtr.Routes()...) + Expect(err).ToNot(HaveOccurred()) + Expect(app).ToNot(BeNil()) + handlerFunc = app.AppFunc() + }) + + Context("unclaimed custodial account", func() { + var unclaimedCustodialUserID string + var unclaimedCustodialProfile user.Profile + var unclaimedCustodialDetails *user.User + var sanitizedUnclaimedCustodialDetails *user.User + var unclaimedRoles []string + var clinicianUserID string + var clinicianProfile user.Profile + var clinicianDetails *user.User + var clinicianRoles []string + BeforeEach(func() { + clinicianUserID = userTest.RandomUserID() + unclaimedCustodialUserID = userTest.RandomUserID() + res = testRest.NewResponseWriter() + res.HeaderOutput = &http.Header{} + req = testRest.NewRequest() + ctx = log.NewContextWithLogger(req.Context(), logTest.NewLogger()) + req.Request = req.WithContext(ctx) + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/users/%s/users", clinicianUserID) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + + unclaimedCustodialProfile = user.Profile{ + FullName: "Unclaimed Custodial", + Birthday: "2001-02-03", + DiagnosisDate: "2002-03-04", + About: "About me", + MRN: "11223344", + } + unclaimedRoles = []string{user.RolePatient} + unclaimedCustodialDetails = &user.User{ + UserID: pointer.FromString(unclaimedCustodialUserID), + Username: pointer.FromString("unclaimed-custodial-automation+999@tidepool.org"), + EmailVerified: pointer.FromBool(false), + Roles: &unclaimedRoles, + Profile: &unclaimedCustodialProfile, + } + sanitizedUnclaimedCustodialDetails = &user.User{ + UserID: pointer.FromString(unclaimedCustodialUserID), + Username: nil, + EmailVerified: pointer.FromBool(false), + Roles: &unclaimedRoles, + Profile: &unclaimedCustodialProfile, + } + + clinicianRoles = []string{user.RoleClinic} + clinicianProfile = user.Profile{ + FullName: "Mr. Clinician", + Clinic: &user.ClinicProfile{}, + } + clinicianDetails = &user.User{ + UserID: pointer.FromString(clinicianUserID), + Username: pointer.FromString("clinician@tidepool.org"), + EmailVerified: pointer.FromBool(true), + Roles: &clinicianRoles, + Profile: &clinicianProfile, + } + + var s string + userAccessor.EXPECT(). + Get(gomock.Any(), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, id string) (*user.User, error) { + switch id { + case clinicianUserID: + return clinicianDetails, nil + case unclaimedCustodialUserID: + return unclaimedCustodialDetails, nil + } + return nil, user.ErrUserNotFound + }).AnyTimes() + + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, id string) (*user.LegacyUserProfile, error) { + switch id { + case clinicianUserID: + return clinicianProfile.ToLegacyProfile(clinicianRoles), nil + case unclaimedCustodialUserID: + return unclaimedCustodialProfile.ToLegacyProfile(unclaimedRoles), nil + } + return nil, user.ErrUserProfileNotFound + }).AnyTimes() + + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), clinicianUserID, unclaimedCustodialUserID). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedToUser(gomock.Any(), clinicianUserID). + Return(permission.Permissions{ + clinicianUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + unclaimedCustodialUserID: permission.Permission{ + permission.Custodian: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), unclaimedCustodialUserID). + Return(permission.Permissions{ + unclaimedCustodialUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + clinicianUserID: permission.Permission{ + permission.Custodian: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), clinicianUserID). + Return(permission.Permissions{ + clinicianUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + }) + + Context("as clinician with custodial permissions", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, clinicianUserID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + var s string + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.AssignableToTypeOf(s), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, granteeID, grantorID string) (bool, error) { + return granteeID == grantorID || (grantorID == unclaimedCustodialUserID && granteeID == clinicianUserID), nil + }).AnyTimes() + }) + It("returns custodial profile w/o the unclaimed email", func() { + userResults := []user.TrustUser{ + { + User: *sanitizedUnclaimedCustodialDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Custodian: struct{}{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + It("returns custodial profile with the unclaimed custodial email", func() { + userResults := []user.TrustUser{ + { + User: *unclaimedCustodialDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Custodian: map[string]any{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + }) + + Context("claimed accounts", func() { + var userProfile user.Profile + var userRoles []string + var userDetails *user.User + var sanitizedUserDetails *user.User + var shareeUserID string + var shareeRoles []string + var shareeProfile user.Profile + var limitedShareeProfile user.Profile + var shareeDetails *user.User + var limitedShareeDetails *user.User + BeforeEach(func() { + userID = userTest.RandomUserID() + shareeUserID = userTest.RandomUserID() + res = testRest.NewResponseWriter() + res.HeaderOutput = &http.Header{} + req = testRest.NewRequest() + ctx = log.NewContextWithLogger(req.Context(), logTest.NewLogger()) + req.Request = req.WithContext(ctx) + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/users/%s/users", shareeUserID) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + + userProfile = user.Profile{ + FullName: "Some User Profile", + Birthday: "2001-02-03", + DiagnosisDate: "2002-03-04", + About: "About me", + MRN: "11223344", + } + userRoles = []string{user.RolePatient} + userDetails = &user.User{ + UserID: pointer.FromString(userID), + Username: pointer.FromString("dev@tidepool.org"), + EmailVerified: pointer.FromBool(true), + Roles: &userRoles, + Profile: &userProfile, + } + sanitizedUserDetails = &user.User{ + UserID: pointer.FromString(userID), + Username: pointer.FromString("dev@tidepool.org"), + EmailVerified: pointer.FromBool(true), + Roles: &userRoles, + Profile: &userProfile, + } + + shareeProfile = user.Profile{ + FullName: "Someone Else's Profile", + Birthday: "2002-03-04", + DiagnosisDate: "2003-04-05", + About: "Not about me", + MRN: "11223346", + } + limitedShareeProfile = user.Profile{ + FullName: "Someone Else's Profile", + } + shareeRoles = []string{user.RolePatient} + shareeDetails = &user.User{ + UserID: pointer.FromString(shareeUserID), + Username: pointer.FromString("sharee@tidepool.org"), + EmailVerified: pointer.FromBool(true), + Roles: &shareeRoles, + Profile: &shareeProfile, + } + limitedShareeDetails = &user.User{ + UserID: pointer.FromString(shareeUserID), + Username: pointer.FromString("sharee@tidepool.org"), + EmailVerified: pointer.FromBool(true), + Roles: &shareeRoles, + Profile: &limitedShareeProfile, + } + + var s string + userAccessor.EXPECT(). + Get(gomock.Any(), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, id string) (*user.User, error) { + switch id { + case userID: + return userDetails, nil + case shareeUserID: + return shareeDetails, nil + } + return nil, user.ErrUserNotFound + }).AnyTimes() + + profileAccessor.EXPECT(). + FindLegacyUserProfile(gomock.Any(), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, id string) (*user.LegacyUserProfile, error) { + switch id { + case userID: + return userProfile.ToLegacyProfile(userRoles), nil + case shareeUserID: + return shareeProfile.ToLegacyProfile(shareeRoles), nil + } + return nil, user.ErrUserProfileNotFound + }).AnyTimes() + + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), shareeUserID, userID). + Return(true, nil).AnyTimes() + + }) + AfterEach(func() { + res.AssertOutputsEmpty() + }) + + Context("with trustor permissions", func() { + BeforeEach(func() { + permsClient.EXPECT(). + PermissionsGrantedToUser(gomock.Any(), userID). + Return(permission.Permissions{ + userID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedToUser(gomock.Any(), shareeUserID). + Return(permission.Permissions{ + shareeUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + userID: permission.Permission{ + permission.Read: map[string]any{}, + }, + }, nil).AnyTimes() + + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), userID). + Return(permission.Permissions{ + userID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), shareeUserID). + Return(permission.Permissions{ + shareeUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + }) + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + It("returns sharer's user info w/ sharee.", func() { + userResults := []user.TrustUser{ + { + User: *userDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Read: map[string]any{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, shareeUserID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + var s string + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.AssignableToTypeOf(s), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, granteeID, grantorID string) (bool, error) { + return granteeID == grantorID || (grantorID == userID && granteeID == shareeUserID), nil + }).AnyTimes() + }) + It("returns sharer's full user info w/ sharee.", func() { + userResults := []user.TrustUser{ + { + User: *sanitizedUserDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Read: struct{}{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + It("excludes self and returns empty if nothing shared.", func() { + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/users/%s/users", userID) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + + userResults := []user.TrustUser{} + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + }) + + Context("with trustee permissions", func() { + BeforeEach(func() { + permsClient.EXPECT(). + PermissionsGrantedToUser(gomock.Any(), userID). + Return(permission.Permissions{ + userID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedToUser(gomock.Any(), shareeUserID). + Return(permission.Permissions{ + shareeUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + userID: permission.Permission{ + permission.Read: map[string]any{}, + }, + }, nil).AnyTimes() + + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), userID). + Return(permission.Permissions{ + userID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + shareeUserID: permission.Permission{ + permission.Read: map[string]any{}, + }, + }, nil).AnyTimes() + permsClient.EXPECT(). + PermissionsGrantedByUser(gomock.Any(), shareeUserID). + Return(permission.Permissions{ + shareeUserID: permission.Permission{ + permission.Owner: map[string]any{}, + }, + }, nil).AnyTimes() + }) + Context("as service", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodServiceSecret, "", authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + It("returns full sharer details if service", func() { + userResults := []user.TrustUser{ + { + User: *userDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Read: struct{}{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + + Context("as user", func() { + BeforeEach(func() { + details = request.NewAuthDetails(request.MethodSessionToken, shareeUserID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + var s string + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.AssignableToTypeOf(s), gomock.AssignableToTypeOf(s)). + DoAndReturn( + func(ctx context.Context, granteeID, grantorID string) (bool, error) { + return granteeID == grantorID || (grantorID == userID && granteeID == shareeUserID), nil + }).AnyTimes() + }) + It("returns sharer's limited user info w/ sharee", func() { + userResults := []user.TrustUser{ + { + User: *sanitizedUserDetails, + TrustPermissions: user.TrustPermissions{ + TrustorPermissions: &permission.Permission{ + permission.Read: struct{}{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + It("returns limited profile info of users if only has trusteePermissions", func() { + req.Method = http.MethodGet + req.URL.Path = fmt.Sprintf("/users/%s/users", userID) + details = request.NewAuthDetails(request.MethodSessionToken, userID, authTest.NewSessionToken()) + req.Request = req.WithContext(request.NewContextWithAuthDetails(req.Context(), details)) + res.WriteOutputs = []testRest.WriteOutput{{BytesWritten: 0, Error: nil}} + + userResults := []user.TrustUser{ + { + User: *limitedShareeDetails, + TrustPermissions: user.TrustPermissions{ + TrusteePermissions: &permission.Permission{ + permission.Read: struct{}{}, + }, + }, + }, + } + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusOK})) + Expect(json.Marshal(userResults)).To(MatchJSON(res.WriteInputs[0])) + }) + }) + }) + }) + }) }) }) }) diff --git a/auth/service/service.go b/auth/service/service.go index 5a9c7d959d..96fb1e0b9a 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -9,9 +9,11 @@ import ( "github.com/tidepool-org/platform/appvalidate" "github.com/tidepool-org/platform/auth" authStore "github.com/tidepool-org/platform/auth/store" + permission "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/provider" "github.com/tidepool-org/platform/service" "github.com/tidepool-org/platform/task" + "github.com/tidepool-org/platform/user" ) type Service interface { @@ -20,6 +22,10 @@ type Service interface { Domain() string AuthStore() authStore.Store + UserAccessor() user.UserAccessor + ProfileAccessor() user.ProfileAccessor + PermissionsClient() permission.Client + ProviderFactory() provider.Factory AuthServiceClient() Client diff --git a/auth/service/service/service.go b/auth/service/service/service.go index 3d6693951c..824c67526b 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -11,6 +11,9 @@ import ( "github.com/kelseyhightower/envconfig" + "github.com/tidepool-org/platform/user" + "github.com/tidepool-org/platform/user/keycloak" + eventsCommon "github.com/tidepool-org/go-common/events" confirmationClient "github.com/tidepool-org/hydrophone/client" @@ -39,6 +42,8 @@ import ( "github.com/tidepool-org/platform/events" "github.com/tidepool-org/platform/log" oauthProvider "github.com/tidepool-org/platform/oauth/provider" + "github.com/tidepool-org/platform/permission" + permissionClient "github.com/tidepool-org/platform/permission/client" "github.com/tidepool-org/platform/platform" "github.com/tidepool-org/platform/provider" providerFactory "github.com/tidepool-org/platform/provider/factory" @@ -79,6 +84,9 @@ type Service struct { partnerSecrets *appvalidate.PartnerSecrets twiistServiceAccountAuthorizer auth.ServiceAccountAuthorizer consentService consent.Service + userAccessor user.UserAccessor + userProfileAccessor user.ProfileAccessor + permsClient *permissionClient.Client } func New() *Service { @@ -155,6 +163,15 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeRouter(); err != nil { return err } + if err := s.initializeUserAccessor(); err != nil { + return err + } + if err := s.initializeUserProfileAccessor(s.userAccessor); err != nil { + return err + } + if err := s.initializePermissionsClient(); err != nil { + return err + } return s.initializeUserEventsHandler() } @@ -216,6 +233,17 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { return s.deviceCheck } +func (s *Service) UserAccessor() user.UserAccessor { + return s.userAccessor +} + +func (s *Service) ProfileAccessor() user.ProfileAccessor { + return s.userProfileAccessor +} + +func (s *Service) PermissionsClient() permission.Client { + return s.permsClient +} func (s *Service) AppValidator() *appvalidate.Validator { return s.appValidator } @@ -529,6 +557,25 @@ func (s *Service) initializeTaskClient() error { return nil } +func (s *Service) initializePermissionsClient() error { + s.Logger().Debug("Loading permission client config") + + cfg := platform.NewConfig() + cfg.UserAgent = s.UserAgent() + reporter := s.ConfigReporter().WithScopes("permission", "client") + loader := platform.NewConfigReporterLoader(reporter) + if err := cfg.Load(loader); err != nil { + return errors.Wrap(err, "unable to load permission client config") + } + + permsClient, err := permissionClient.New(cfg, platform.AuthorizeAsService) + if err != nil { + return errors.Wrap(err, "unable to create permission client") + } + s.permsClient = permsClient + return nil +} + func (s *Service) terminateTaskClient() { if s.taskClient != nil { s.Logger().Debug("Destroying task client") @@ -663,6 +710,46 @@ func (s *Service) initializeUserEventsHandler() error { return nil } +func (s *Service) initializeUserAccessor() error { + s.Logger().Debug("Initializing user accessor") + + config := &keycloak.KeycloakConfig{} + if err := config.FromEnv(); err != nil { + return err + } + s.userAccessor = keycloak.NewKeycloakUserAccessor(config) + + return nil +} + +func (s *Service) initializeUserProfileAccessor(userAccessor user.UserAccessor) error { + s.Logger().Debug("Initializing user profile accessor") + + if userAccessor == nil { + return errors.New("empty user accessor passed to initializeUserProfileAccessor") + } + cfg := storeStructuredMongo.NewConfig() + // Note the "SEAGULL" prefix, this is so that the regular env vars + // for mongo access such as TIDEPOOL_STORE_SCHEME are + // SEAGULL_TIDEPOOL_STORE_SCHEME so as to not conflict with existing + // TIDEPOOL_STORE_SCHEME values. This is done instead of using a + // seagull client as seagull will eventually be removed so no sense + // in keeping it around. + if err := cfg.LoadPrefix("SEAGULL"); err != nil { + return errors.Wrap(err, "unable to load seagull profile accessor config") + } + + s.Logger().Debug("creating legacy seagull profile accessor") + + repo, err := authStoreMongo.NewLegacySeagullProfileRepository(cfg) + if err != nil { + return errors.Wrap(err, "unable to create fallback user profile repository") + } + + s.userProfileAccessor = user.NewFallbackLegacyUserAccessor(repo, userAccessor, userAccessor) + return nil +} + func (s *Service) initializeDeviceCheck() error { s.Logger().Debug("Initializing device check") diff --git a/auth/service/service/service_test.go b/auth/service/service/service_test.go index bb8cb9706d..4389b6b8d5 100644 --- a/auth/service/service/service_test.go +++ b/auth/service/service/service_test.go @@ -32,11 +32,13 @@ var _ = Describe("Service", func() { var serverSecret string var sessionToken string var server *Server + var authClientConfig map[string]interface{} var authStoreConfig map[string]interface{} var dataClientConfig map[string]interface{} var dataSourceClientConfig map[string]interface{} var taskClientConfig map[string]interface{} + var permissionClientConfig map[string]any var userClientConfig map[string]interface{} var authServiceConfig map[string]interface{} var service *authServiceService.Service @@ -57,26 +59,30 @@ var _ = Describe("Service", func() { RespondWith(http.StatusOK, nil, http.Header{"X-Tidepool-Session-Token": []string{sessionToken}})), ) - authClientConfig = map[string]interface{}{ - "external": map[string]interface{}{ + authClientConfig = map[string]any{ + "external": map[string]any{ "address": server.URL(), "server_session_token_secret": serverSecret, }, } - authStoreConfig = map[string]interface{}{ + authStoreConfig = map[string]any{ "addresses": os.Getenv("TIDEPOOL_STORE_ADDRESSES"), "database": test.RandomStringFromRangeAndCharset(4, 8, test.CharsetLowercase), "tls": "false", } - dataClientConfig = map[string]interface{}{ + dataClientConfig = map[string]any{ "address": server.URL(), "server_token_secret": authTest.NewServiceSecret(), } - dataSourceClientConfig = map[string]interface{}{ + dataSourceClientConfig = map[string]any{ "address": server.URL(), "server_token_secret": authTest.NewServiceSecret(), } - taskClientConfig = map[string]interface{}{ + taskClientConfig = map[string]any{ + "address": server.URL(), + "server_token_secret": authTest.NewServiceSecret(), + } + permissionClientConfig = map[string]any{ "address": server.URL(), "server_token_secret": authTest.NewServiceSecret(), } @@ -85,24 +91,27 @@ var _ = Describe("Service", func() { "server_token_secret": authTest.NewServiceSecret(), } - authServiceConfig = map[string]interface{}{ - "auth": map[string]interface{}{ + authServiceConfig = map[string]any{ + "auth": map[string]any{ "client": authClientConfig, "store": authStoreConfig, }, - "data": map[string]interface{}{ + "data": map[string]any{ "client": dataClientConfig, }, - "data_source": map[string]interface{}{ + "permission": map[string]any{ + "client": permissionClientConfig, + }, + "data_source": map[string]any{ "client": dataSourceClientConfig, }, "domain": "test.com", "secret": authTest.NewServiceSecret(), - "server": map[string]interface{}{ + "server": map[string]any{ "address": testHttp.NewAddress(), "tls": "false", }, - "task": map[string]interface{}{ + "task": map[string]any{ "client": taskClientConfig, }, "user": map[string]interface{}{ diff --git a/auth/service/test/service.go b/auth/service/test/service.go index c8b16fdb67..08be08c2d7 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -4,6 +4,7 @@ import ( "context" "github.com/onsi/gomega" + gomock "go.uber.org/mock/gomock" confirmationClient "github.com/tidepool-org/hydrophone/client" @@ -13,11 +14,13 @@ import ( authService "github.com/tidepool-org/platform/auth/service" authStore "github.com/tidepool-org/platform/auth/store" authStoreTest "github.com/tidepool-org/platform/auth/store/test" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/provider" providerTest "github.com/tidepool-org/platform/provider/test" serviceTest "github.com/tidepool-org/platform/service/test" "github.com/tidepool-org/platform/task" taskTest "github.com/tidepool-org/platform/task/test" + "github.com/tidepool-org/platform/user" ) type Service struct { @@ -44,6 +47,9 @@ type Service struct { PartnerSecretsImpl *appvalidate.PartnerSecrets TwiistServiceAccountAuthorizerInvocations int TwiistServiceAccountAuthorizerImpl auth.ServiceAccountAuthorizer + userAccessor user.UserAccessor + permsClient permission.Client + profileAccessor user.ProfileAccessor } func NewService() *Service { @@ -55,6 +61,24 @@ func NewService() *Service { } } +// NewMockedService uses a combination of the "old" style manual stub / fakes / +// mocks and newer gomocks for convenience so that the current code doesn't +// have to be refactored too much +func NewMockedService(ctrl *gomock.Controller) (svc *Service, userAccessor *user.MockUserAccessor, profileAccessor *user.MockProfileAccessor, permsClient *permission.MockClient) { + userAccessor = user.NewMockUserAccessor(ctrl) + profileAccessor = user.NewMockProfileAccessor(ctrl) + permsClient = permission.NewMockClient(ctrl) + return &Service{ + Service: serviceTest.NewService(), + AuthStoreImpl: authStoreTest.NewStore(), + ProviderFactoryImpl: providerTest.NewFactory(), + TaskClientImpl: taskTest.NewClient(), + userAccessor: userAccessor, + profileAccessor: profileAccessor, + permsClient: permsClient, + }, userAccessor, profileAccessor, permsClient +} + func (s *Service) Domain() string { s.DomainInvocations++ @@ -111,6 +135,10 @@ func (s *Service) Status(ctx context.Context) *authService.Status { return output } +func (s *Service) PermissionsClient() permission.Client { + return s.permsClient +} + func (s *Service) AppValidator() *appvalidate.Validator { s.AppvalidateValidatorInvocations++ @@ -136,3 +164,11 @@ func (s *Service) Expectations() { s.TaskClientImpl.Expectations() gomega.Expect(s.StatusOutputs).To(gomega.BeEmpty()) } + +func (s *Service) UserAccessor() user.UserAccessor { + return s.userAccessor +} + +func (s *Service) ProfileAccessor() user.ProfileAccessor { + return s.profileAccessor +} diff --git a/auth/store/mongo/legacy_seagull_profile_repository.go b/auth/store/mongo/legacy_seagull_profile_repository.go new file mode 100644 index 0000000000..d43ad565f4 --- /dev/null +++ b/auth/store/mongo/legacy_seagull_profile_repository.go @@ -0,0 +1,114 @@ +package mongo + +import ( + "context" + stdErrors "errors" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/tidepool-org/platform/errors" + "github.com/tidepool-org/platform/log" + storeStructuredMongo "github.com/tidepool-org/platform/store/structured/mongo" + structureValidator "github.com/tidepool-org/platform/structure/validator" + "github.com/tidepool-org/platform/user" +) + +// LegacySeagullProfileRepository accesses legacy seagull profiles while the +// seagll migration to keycloak is in progress. +type LegacySeagullProfileRepository struct { + *storeStructuredMongo.Repository +} + +func NewLegacySeagullProfileRepository(c *storeStructuredMongo.Config) (*LegacySeagullProfileRepository, error) { + if c == nil { + return nil, errors.New("config is missing") + } + + store, err := storeStructuredMongo.NewStore(c) + if err != nil { + return nil, err + } + return &LegacySeagullProfileRepository{ + store.GetRepository("seagull"), + }, nil +} + +func (p *LegacySeagullProfileRepository) EnsureIndexes() error { + return nil +} + +func (p *LegacySeagullProfileRepository) FindLegacyUserProfile(ctx context.Context, userID string) (*user.LegacyUserProfile, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if userID == "" { + return nil, errors.New("user id is missing") + } + selector := bson.M{ + "userId": userID, + } + var doc user.LegacySeagullDocument + if err := p.FindOne(ctx, selector).Decode(&doc); err != nil { + if stdErrors.Is(err, mongo.ErrNoDocuments) { + return nil, user.ErrUserProfileNotFound + } + return nil, err + } + + return doc.ToLegacyProfile() +} + +func (p *LegacySeagullProfileRepository) UpdateLegacyUserProfile(ctx context.Context, userID string, profile *user.LegacyUserProfile) error { + if ctx == nil { + return errors.New("context is missing") + } + if userID == "" { + return errors.New("user id is missing") + } + if err := structureValidator.New(log.LoggerFromContext(ctx)).Validate(profile); err != nil { + return err + } + var doc user.LegacySeagullDocument + selector := bson.M{ + "userId": userID, + } + err := p.FindOne(ctx, selector).Decode(&doc) + // A user can have no profile set - see seagull/lib/routes/seagullApi.js `if (err.statusCode == 404 && addIfNotThere)` + if err != nil && !stdErrors.Is(err, mongo.ErrNoDocuments) { + return err + } + hasExistingProfile := err == nil + // We need to make a distinction b/t a seagull profile not existing (in which case we can upsert) versus a seagull profile actively being migrated, which is why we need to actually read the document. + if hasExistingProfile && doc.IsMigrating() { + return user.ErrUserProfileMigrationInProgress + } + + // This will create a new value even if doc.Value is empty + updatedValueRaw, err := user.AddProfileToSeagullValue(doc.Value, profile) + if err != nil { + return err + } + + uopts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After) + uselector := bson.M{ + "userId": userID, + } + update := bson.M{ + "$set": bson.M{ + "value": updatedValueRaw, + "userId": userID, // Set because of possible upsert + }, + } + var updatedDoc user.LegacySeagullDocument + err = p.FindOneAndUpdate(ctx, uselector, update, uopts).Decode(&updatedDoc) + if err != nil { + return err + } + // Handle case where a migration was started in between the start of this function and the update + if updatedDoc.IsMigrating() { + return user.ErrUserProfileMigrationInProgress + } + return nil +} diff --git a/auth/test/auth_mocks.go b/auth/test/auth_mocks.go index d2b56c2092..ea976a65d7 100644 --- a/auth/test/auth_mocks.go +++ b/auth/test/auth_mocks.go @@ -219,6 +219,21 @@ func (mr *MockClientMockRecorder) GetUserPermissions(ctx, requestUserID, targetU return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPermissions", reflect.TypeOf((*MockClient)(nil).GetUserPermissions), ctx, requestUserID, targetUserID) } +// HasCustodianPermissions mocks base method. +func (m *MockClient) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasCustodianPermissions", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasCustodianPermissions indicates an expected call of HasCustodianPermissions. +func (mr *MockClientMockRecorder) HasCustodianPermissions(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCustodianPermissions", reflect.TypeOf((*MockClient)(nil).HasCustodianPermissions), ctx, granteeUserID, grantorUserID) +} + // ListProviderSessions mocks base method. func (m *MockClient) ListProviderSessions(ctx context.Context, filter *auth.ProviderSessionFilter, pagination *page.Pagination) (auth.ProviderSessions, error) { m.ctrl.T.Helper() @@ -249,6 +264,36 @@ func (mr *MockClientMockRecorder) ListUserRestrictedTokens(ctx, userID, filter, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserRestrictedTokens", reflect.TypeOf((*MockClient)(nil).ListUserRestrictedTokens), ctx, userID, filter, pagination) } +// PermissionsGrantedByUser mocks base method. +func (m *MockClient) PermissionsGrantedByUser(ctx context.Context, sharerID string) (permission.Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionsGrantedByUser", ctx, sharerID) + ret0, _ := ret[0].(permission.Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermissionsGrantedByUser indicates an expected call of PermissionsGrantedByUser. +func (mr *MockClientMockRecorder) PermissionsGrantedByUser(ctx, sharerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionsGrantedByUser", reflect.TypeOf((*MockClient)(nil).PermissionsGrantedByUser), ctx, sharerID) +} + +// PermissionsGrantedToUser mocks base method. +func (m *MockClient) PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (permission.Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionsGrantedToUser", ctx, granteeUserID) + ret0, _ := ret[0].(permission.Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermissionsGrantedToUser indicates an expected call of PermissionsGrantedToUser. +func (mr *MockClientMockRecorder) PermissionsGrantedToUser(ctx, granteeUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionsGrantedToUser", reflect.TypeOf((*MockClient)(nil).PermissionsGrantedToUser), ctx, granteeUserID) +} + // ServerSessionToken mocks base method. func (m *MockClient) ServerSessionToken() (string, error) { m.ctrl.T.Helper() @@ -308,6 +353,21 @@ func (mr *MockClientMockRecorder) UpdateUserPermissions(ctx, requestUserID, targ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPermissions", reflect.TypeOf((*MockClient)(nil).UpdateUserPermissions), ctx, requestUserID, targetUserID, permissions) } +// UsersHaveSharingRelationship mocks base method. +func (m *MockClient) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsersHaveSharingRelationship", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UsersHaveSharingRelationship indicates an expected call of UsersHaveSharingRelationship. +func (mr *MockClientMockRecorder) UsersHaveSharingRelationship(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsersHaveSharingRelationship", reflect.TypeOf((*MockClient)(nil).UsersHaveSharingRelationship), ctx, granteeUserID, grantorUserID) +} + // ValidateSessionToken mocks base method. func (m *MockClient) ValidateSessionToken(ctx context.Context, token string) (request.AuthDetails, error) { m.ctrl.T.Helper() diff --git a/auth/test/client.go b/auth/test/client.go index e500f69d34..15c8e1d7eb 100644 --- a/auth/test/client.go +++ b/auth/test/client.go @@ -1,5 +1,11 @@ package test +import ( + "context" + + "github.com/tidepool-org/platform/permission" +) + type Client struct { *ProviderSessionAccessor *RestrictedTokenAccessor @@ -19,3 +25,27 @@ func (c *Client) AssertOutputsEmpty() { c.RestrictedTokenAccessor.Expectations() c.ExternalAccessor.AssertOutputsEmpty() } + +func (c *Client) GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (permission.Permissions, error) { + return c.ExternalAccessor.Client.GetUserPermissions(ctx, requestUserID, targetUserID) +} + +func (c *Client) UpdateUserPermissions(ctx context.Context, requestUserID string, targetUserID string, permissions permission.Permissions) error { + return c.ExternalAccessor.Client.UpdateUserPermissions(ctx, requestUserID, targetUserID, permissions) +} + +func (c *Client) PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (permission.Permissions, error) { + return nil, nil +} + +func (c *Client) PermissionsGrantedByUser(ctx context.Context, sharerID string) (permission.Permissions, error) { + return nil, nil +} + +func (c *Client) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + return false, nil +} + +func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + return false, nil +} diff --git a/auth/user.go b/auth/user.go index 7f90a955b1..1ae0d0e0bc 100644 --- a/auth/user.go +++ b/auth/user.go @@ -1,12 +1,11 @@ package auth import ( - "regexp" - "github.com/tidepool-org/platform/errors" "github.com/tidepool-org/platform/id" "github.com/tidepool-org/platform/structure" structureValidator "github.com/tidepool-org/platform/structure/validator" + "github.com/tidepool-org/platform/user" ) func NewUserID() string { @@ -24,7 +23,7 @@ func UserIDValidator(value string, errorReporter structure.ErrorReporter) { func ValidateUserID(value string) error { if value == "" { return structureValidator.ErrorValueEmpty() - } else if !idExpression.MatchString(value) { + } else if !user.IdExpression.MatchString(value) { return ErrorValueStringAsUserIDNotValid(value) } return nil @@ -33,5 +32,3 @@ func ValidateUserID(value string) error { func ErrorValueStringAsUserIDNotValid(value string) error { return errors.Preparedf(structureValidator.ErrorCodeValueNotValid, "value is not valid", "value %q is not valid as user id", value) } - -var idExpression = regexp.MustCompile("\\A(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{10})\\z") diff --git a/data/client/test/mock.go b/data/client/test/mock.go new file mode 100644 index 0000000000..b7b11091ac --- /dev/null +++ b/data/client/test/mock.go @@ -0,0 +1,267 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/data/client (interfaces: Client) +// +// Generated by this command: +// +// mockgen -destination=./test/mock.go -package test . Client +// + +// Package test is a generated GoMock package. +package test + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + + data "github.com/tidepool-org/platform/data" + page "github.com/tidepool-org/platform/page" + types "github.com/tidepool-org/platform/summary/types" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// CreateDataSetsData mocks base method. +func (m *MockClient) CreateDataSetsData(ctx context.Context, dataSetID string, datumArray []data.Datum) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateDataSetsData", ctx, dataSetID, datumArray) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateDataSetsData indicates an expected call of CreateDataSetsData. +func (mr *MockClientMockRecorder) CreateDataSetsData(ctx, dataSetID, datumArray any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDataSetsData", reflect.TypeOf((*MockClient)(nil).CreateDataSetsData), ctx, dataSetID, datumArray) +} + +// CreateUserDataSet mocks base method. +func (m *MockClient) CreateUserDataSet(ctx context.Context, userID string, create *data.DataSetCreate) (*data.DataSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserDataSet", ctx, userID, create) + ret0, _ := ret[0].(*data.DataSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUserDataSet indicates an expected call of CreateUserDataSet. +func (mr *MockClientMockRecorder) CreateUserDataSet(ctx, userID, create any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserDataSet", reflect.TypeOf((*MockClient)(nil).CreateUserDataSet), ctx, userID, create) +} + +// DeleteDataSet mocks base method. +func (m *MockClient) DeleteDataSet(ctx context.Context, id string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDataSet", ctx, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDataSet indicates an expected call of DeleteDataSet. +func (mr *MockClientMockRecorder) DeleteDataSet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDataSet", reflect.TypeOf((*MockClient)(nil).DeleteDataSet), ctx, id) +} + +// DestroyDataForUserByID mocks base method. +func (m *MockClient) DestroyDataForUserByID(ctx context.Context, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DestroyDataForUserByID", ctx, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DestroyDataForUserByID indicates an expected call of DestroyDataForUserByID. +func (mr *MockClientMockRecorder) DestroyDataForUserByID(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DestroyDataForUserByID", reflect.TypeOf((*MockClient)(nil).DestroyDataForUserByID), ctx, userID) +} + +// GetBGMSummary mocks base method. +func (m *MockClient) GetBGMSummary(ctx context.Context, id string) (*types.Summary[*types.BGMPeriods, *types.GlucoseBucket, types.BGMPeriods, types.GlucoseBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBGMSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.BGMPeriods, *types.GlucoseBucket, types.BGMPeriods, types.GlucoseBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBGMSummary indicates an expected call of GetBGMSummary. +func (mr *MockClientMockRecorder) GetBGMSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBGMSummary", reflect.TypeOf((*MockClient)(nil).GetBGMSummary), ctx, id) +} + +// GetCGMSummary mocks base method. +func (m *MockClient) GetCGMSummary(ctx context.Context, id string) (*types.Summary[*types.CGMPeriods, *types.GlucoseBucket, types.CGMPeriods, types.GlucoseBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCGMSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.CGMPeriods, *types.GlucoseBucket, types.CGMPeriods, types.GlucoseBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCGMSummary indicates an expected call of GetCGMSummary. +func (mr *MockClientMockRecorder) GetCGMSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCGMSummary", reflect.TypeOf((*MockClient)(nil).GetCGMSummary), ctx, id) +} + +// GetContinuousSummary mocks base method. +func (m *MockClient) GetContinuousSummary(ctx context.Context, id string) (*types.Summary[*types.ContinuousPeriods, *types.ContinuousBucket, types.ContinuousPeriods, types.ContinuousBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContinuousSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.ContinuousPeriods, *types.ContinuousBucket, types.ContinuousPeriods, types.ContinuousBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContinuousSummary indicates an expected call of GetContinuousSummary. +func (mr *MockClientMockRecorder) GetContinuousSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContinuousSummary", reflect.TypeOf((*MockClient)(nil).GetContinuousSummary), ctx, id) +} + +// GetDataSet mocks base method. +func (m *MockClient) GetDataSet(ctx context.Context, id string) (*data.DataSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDataSet", ctx, id) + ret0, _ := ret[0].(*data.DataSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDataSet indicates an expected call of GetDataSet. +func (mr *MockClientMockRecorder) GetDataSet(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDataSet", reflect.TypeOf((*MockClient)(nil).GetDataSet), ctx, id) +} + +// GetMigratableUserIDs mocks base method. +func (m *MockClient) GetMigratableUserIDs(ctx context.Context, t string, pagination *page.Pagination) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMigratableUserIDs", ctx, t, pagination) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetMigratableUserIDs indicates an expected call of GetMigratableUserIDs. +func (mr *MockClientMockRecorder) GetMigratableUserIDs(ctx, t, pagination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMigratableUserIDs", reflect.TypeOf((*MockClient)(nil).GetMigratableUserIDs), ctx, t, pagination) +} + +// GetOutdatedUserIDs mocks base method. +func (m *MockClient) GetOutdatedUserIDs(ctx context.Context, t string, pagination *page.Pagination) (*types.OutdatedSummariesResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOutdatedUserIDs", ctx, t, pagination) + ret0, _ := ret[0].(*types.OutdatedSummariesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOutdatedUserIDs indicates an expected call of GetOutdatedUserIDs. +func (mr *MockClientMockRecorder) GetOutdatedUserIDs(ctx, t, pagination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOutdatedUserIDs", reflect.TypeOf((*MockClient)(nil).GetOutdatedUserIDs), ctx, t, pagination) +} + +// ListUserDataSets mocks base method. +func (m *MockClient) ListUserDataSets(ctx context.Context, userID string, filter *data.DataSetFilter, pagination *page.Pagination) (data.DataSets, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserDataSets", ctx, userID, filter, pagination) + ret0, _ := ret[0].(data.DataSets) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserDataSets indicates an expected call of ListUserDataSets. +func (mr *MockClientMockRecorder) ListUserDataSets(ctx, userID, filter, pagination any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserDataSets", reflect.TypeOf((*MockClient)(nil).ListUserDataSets), ctx, userID, filter, pagination) +} + +// UpdateBGMSummary mocks base method. +func (m *MockClient) UpdateBGMSummary(ctx context.Context, id string) (*types.Summary[*types.BGMPeriods, *types.GlucoseBucket, types.BGMPeriods, types.GlucoseBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateBGMSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.BGMPeriods, *types.GlucoseBucket, types.BGMPeriods, types.GlucoseBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateBGMSummary indicates an expected call of UpdateBGMSummary. +func (mr *MockClientMockRecorder) UpdateBGMSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateBGMSummary", reflect.TypeOf((*MockClient)(nil).UpdateBGMSummary), ctx, id) +} + +// UpdateCGMSummary mocks base method. +func (m *MockClient) UpdateCGMSummary(ctx context.Context, id string) (*types.Summary[*types.CGMPeriods, *types.GlucoseBucket, types.CGMPeriods, types.GlucoseBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCGMSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.CGMPeriods, *types.GlucoseBucket, types.CGMPeriods, types.GlucoseBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCGMSummary indicates an expected call of UpdateCGMSummary. +func (mr *MockClientMockRecorder) UpdateCGMSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCGMSummary", reflect.TypeOf((*MockClient)(nil).UpdateCGMSummary), ctx, id) +} + +// UpdateContinuousSummary mocks base method. +func (m *MockClient) UpdateContinuousSummary(ctx context.Context, id string) (*types.Summary[*types.ContinuousPeriods, *types.ContinuousBucket, types.ContinuousPeriods, types.ContinuousBucket], error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateContinuousSummary", ctx, id) + ret0, _ := ret[0].(*types.Summary[*types.ContinuousPeriods, *types.ContinuousBucket, types.ContinuousPeriods, types.ContinuousBucket]) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateContinuousSummary indicates an expected call of UpdateContinuousSummary. +func (mr *MockClientMockRecorder) UpdateContinuousSummary(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateContinuousSummary", reflect.TypeOf((*MockClient)(nil).UpdateContinuousSummary), ctx, id) +} + +// UpdateDataSet mocks base method. +func (m *MockClient) UpdateDataSet(ctx context.Context, id string, update *data.DataSetUpdate) (*data.DataSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDataSet", ctx, id, update) + ret0, _ := ret[0].(*data.DataSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateDataSet indicates an expected call of UpdateDataSet. +func (mr *MockClientMockRecorder) UpdateDataSet(ctx, id, update any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDataSet", reflect.TypeOf((*MockClient)(nil).UpdateDataSet), ctx, id, update) +} diff --git a/data/service/api/v1/mocks/mocklogger_test_gen.go b/data/service/api/v1/mocks/mocklogger_test_gen.go new file mode 100644 index 0000000000..db4d543f37 --- /dev/null +++ b/data/service/api/v1/mocks/mocklogger_test_gen.go @@ -0,0 +1,268 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/log (interfaces: Logger) +// +// Generated by this command: +// +// mockgen -destination mocks/mocklogger_test_gen.go -package mocks github.com/tidepool-org/platform/log Logger +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + + log "github.com/tidepool-org/platform/log" +) + +// MockLogger is a mock of Logger interface. +type MockLogger struct { + ctrl *gomock.Controller + recorder *MockLoggerMockRecorder + isgomock struct{} +} + +// MockLoggerMockRecorder is the mock recorder for MockLogger. +type MockLoggerMockRecorder struct { + mock *MockLogger +} + +// NewMockLogger creates a new mock instance. +func NewMockLogger(ctrl *gomock.Controller) *MockLogger { + mock := &MockLogger{ctrl: ctrl} + mock.recorder = &MockLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { + return m.recorder +} + +// Debug mocks base method. +func (m *MockLogger) Debug(message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Debug", message) +} + +// Debug indicates an expected call of Debug. +func (mr *MockLoggerMockRecorder) Debug(message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), message) +} + +// Debugf mocks base method. +func (m *MockLogger) Debugf(message string, args ...any) { + m.ctrl.T.Helper() + varargs := []any{message} + for _, a := range args { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugf", varargs...) +} + +// Debugf indicates an expected call of Debugf. +func (mr *MockLoggerMockRecorder) Debugf(message any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{message}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) +} + +// Error mocks base method. +func (m *MockLogger) Error(message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Error", message) +} + +// Error indicates an expected call of Error. +func (mr *MockLoggerMockRecorder) Error(message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), message) +} + +// Errorf mocks base method. +func (m *MockLogger) Errorf(message string, args ...any) { + m.ctrl.T.Helper() + varargs := []any{message} + for _, a := range args { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Errorf", varargs...) +} + +// Errorf indicates an expected call of Errorf. +func (mr *MockLoggerMockRecorder) Errorf(message any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{message}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*MockLogger)(nil).Errorf), varargs...) +} + +// Info mocks base method. +func (m *MockLogger) Info(message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Info", message) +} + +// Info indicates an expected call of Info. +func (mr *MockLoggerMockRecorder) Info(message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), message) +} + +// Infof mocks base method. +func (m *MockLogger) Infof(message string, args ...any) { + m.ctrl.T.Helper() + varargs := []any{message} + for _, a := range args { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infof", varargs...) +} + +// Infof indicates an expected call of Infof. +func (mr *MockLoggerMockRecorder) Infof(message any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{message}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) +} + +// Level mocks base method. +func (m *MockLogger) Level() log.Level { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Level") + ret0, _ := ret[0].(log.Level) + return ret0 +} + +// Level indicates an expected call of Level. +func (mr *MockLoggerMockRecorder) Level() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Level", reflect.TypeOf((*MockLogger)(nil).Level)) +} + +// Log mocks base method. +func (m *MockLogger) Log(level log.Level, message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Log", level, message) +} + +// Log indicates an expected call of Log. +func (mr *MockLoggerMockRecorder) Log(level, message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogger)(nil).Log), level, message) +} + +// Warn mocks base method. +func (m *MockLogger) Warn(message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Warn", message) +} + +// Warn indicates an expected call of Warn. +func (mr *MockLoggerMockRecorder) Warn(message any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), message) +} + +// Warnf mocks base method. +func (m *MockLogger) Warnf(message string, args ...any) { + m.ctrl.T.Helper() + varargs := []any{message} + for _, a := range args { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *MockLoggerMockRecorder) Warnf(message any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{message}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...) +} + +// WithError mocks base method. +func (m *MockLogger) WithError(err error) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithError", err) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithError indicates an expected call of WithError. +func (mr *MockLoggerMockRecorder) WithError(err any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithError", reflect.TypeOf((*MockLogger)(nil).WithError), err) +} + +// WithField mocks base method. +func (m *MockLogger) WithField(key string, value any) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithField", key, value) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithField indicates an expected call of WithField. +func (mr *MockLoggerMockRecorder) WithField(key, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithField", reflect.TypeOf((*MockLogger)(nil).WithField), key, value) +} + +// WithFields mocks base method. +func (m *MockLogger) WithFields(fields log.Fields) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithFields", fields) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithFields indicates an expected call of WithFields. +func (mr *MockLoggerMockRecorder) WithFields(fields any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithFields", reflect.TypeOf((*MockLogger)(nil).WithFields), fields) +} + +// WithLevel mocks base method. +func (m *MockLogger) WithLevel(level log.Level) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithLevel", level) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithLevel indicates an expected call of WithLevel. +func (mr *MockLoggerMockRecorder) WithLevel(level any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithLevel", reflect.TypeOf((*MockLogger)(nil).WithLevel), level) +} + +// WithLevelRank mocks base method. +func (m *MockLogger) WithLevelRank(level log.Level, rank log.Rank) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithLevelRank", level, rank) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithLevelRank indicates an expected call of WithLevelRank. +func (mr *MockLoggerMockRecorder) WithLevelRank(level, rank any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithLevelRank", reflect.TypeOf((*MockLogger)(nil).WithLevelRank), level, rank) +} + +// WithLevelRanks mocks base method. +func (m *MockLogger) WithLevelRanks(levelRanks log.LevelRanks) log.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithLevelRanks", levelRanks) + ret0, _ := ret[0].(log.Logger) + return ret0 +} + +// WithLevelRanks indicates an expected call of WithLevelRanks. +func (mr *MockLoggerMockRecorder) WithLevelRanks(levelRanks any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithLevelRanks", reflect.TypeOf((*MockLogger)(nil).WithLevelRanks), levelRanks) +} diff --git a/data/service/api/v1/mocks/mocks.go b/data/service/api/v1/mocks/mocks.go index 5637bbdd68..1e0d2e4324 100644 --- a/data/service/api/v1/mocks/mocks.go +++ b/data/service/api/v1/mocks/mocks.go @@ -86,3 +86,19 @@ func (p *Permission) GetUserPermissions(ctx context.Context, requestUserID strin func (p *Permission) UpdateUserPermissions(ctx context.Context, requestUserID string, targetUserID string, perms permission.Permissions) error { panic("not implemented") } + +func (c *Permission) PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (permission.Permissions, error) { + return nil, nil +} + +func (c *Permission) PermissionsGrantedByUser(ctx context.Context, sharerID string) (permission.Permissions, error) { + return nil, nil +} + +func (c *Permission) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + return false, nil +} + +func (c *Permission) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + return false, nil +} diff --git a/env.sh b/env.sh index 13f8a66e31..fd672e573d 100644 --- a/env.sh +++ b/env.sh @@ -61,3 +61,24 @@ export TIDEPOOL_NOTIFICATION_SERVICE_SECRET="Service secret used for interservic export TIDEPOOL_PRESCRIPTION_SERVICE_SECRET="Service secret used for interservice requests with the prescription service" export TIDEPOOL_TASK_SERVICE_SECRET="Service secret used for interservice requests with the task service" export TIDEPOOL_USER_SERVICE_SECRET="Service secret used for interservice requests with the user service" + +export TIDEPOOL_KEYCLOAK_CLIENT_ID="client_id" +export TIDEPOOL_KEYCLOAK_CLIENT_SECRET="client_secret" +export TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_ID="long_lived_client_id" +export TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_SECRET="long_lived_client_secret" +export TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_ID="backend_client_id" +export TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_SECRET="backend_client_secret" +export TIDEPOOL_KEYCLOAK_BASE_URL="http://localhost:8080" +export TIDEPOOL_KEYCLOAK_REALM="realm" +export TIDEPOOL_KEYCLOAK_ADMIN_USERNAME="admin_username" +export TIDEPOOL_KEYCLOAK_ADMIN_PASSWORD="admin_password" + +# legacy seagull env vars until profiles migrated. +export SEAGULL_TIDEPOOL_STORE_SCHEME="mongodb" +export SEAGULL_TIDEPOOL_STORE_ADDRESSES="localhost:27017" +export SEAGULL_TIDEPOOL_STORE_TLS="false" +export SEAGULL_TIDEPOOL_STORE_DATABASE="seagull" +export SEAGULL_TIDEPOOL_STORE_USERNAME="admin_username" +export SEAGULL_TIDEPOOL_STORE_PASSWORD="admin_password" +export SEAGULL_TIDEPOOL_STORE_OPT_PARAMS="authSource=admin" + diff --git a/env.test.sh b/env.test.sh index cd71c2c36b..2e4f4881b6 100644 --- a/env.test.sh +++ b/env.test.sh @@ -17,3 +17,15 @@ export TIDEPOOL_PROFILE_STORE_DATABASE="seagull_test" export TIDEPOOL_SESSION_STORE_DATABASE="user_test" export TIDEPOOL_SYNC_TASK_STORE_DATABASE="data_test" export TIDEPOOL_USER_STORE_DATABASE="user_test" + +export TIDEPOOL_KEYCLOAK_CLIENT_ID="client_id" +export TIDEPOOL_KEYCLOAK_CLIENT_SECRET="client_secret" +export TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_ID="long_lived_client_id" +export TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_SECRET="long_lived_client_secret" +export TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_ID="backend_client_id" +export TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_SECRET="backend_client_secret" +export TIDEPOOL_KEYCLOAK_BASE_URL="http://localhost:8080" +export TIDEPOOL_KEYCLOAK_REALM="realm" +export TIDEPOOL_KEYCLOAK_ADMIN_USERNAME="admin_username" +export TIDEPOOL_KEYCLOAK_ADMIN_PASSWORD="admin_password" + diff --git a/go.mod b/go.mod index 121830c48c..8b03d984cd 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/IBM/sarama v1.45.1 + github.com/Nerzal/gocloak/v13 v13.9.0 github.com/ant0ine/go-json-rest v3.3.2+incompatible github.com/aws/aws-sdk-go v1.55.6 github.com/bas-d/appattest v0.1.0 @@ -11,6 +12,7 @@ require ( github.com/deckarep/golang-set/v2 v2.8.0 github.com/githubnemo/CompileDaemon v1.4.0 github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/golang/mock v1.6.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/gowebpki/jcs v1.0.1 @@ -64,9 +66,10 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.2 // indirect + github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/golang/mock v1.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect @@ -97,7 +100,9 @@ require ( github.com/montanaflynn/stats v0.7.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oapi-codegen/runtime v1.1.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect @@ -105,6 +110,7 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/go.sum b/go.sum index 4034e852d4..f1866f36af 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoP github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/IBM/sarama v1.45.1 h1:nY30XqYpqyXOXSNoe2XCgjj9jklGM1Ye94ierUb1jQ0= github.com/IBM/sarama v1.45.1/go.mod h1:qifDhA3VWSrQ1TjSMyxDl3nYL3oX2C83u+G6L79sq4w= +github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw= +github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/ant0ine/go-json-rest v3.3.2+incompatible h1:nBixrkLFiDNAW0hauKDLc8yJI6XfrQumWvytE1Hk14E= github.com/ant0ine/go-json-rest v3.3.2+incompatible/go.mod h1:q6aCt0GfU6LhpBsnZ/2U+mwe+0XB5WStbmwyoPfc+sk= @@ -62,12 +64,16 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -172,8 +178,12 @@ github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -196,6 +206,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/solworktech/md2pdf/v2 v2.2.18 h1:wxNOtNNAnrB7YqJARq9t85dQ9sk7X+56MPRkmDzepJE= @@ -292,6 +304,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -311,6 +324,7 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -327,6 +341,7 @@ golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= diff --git a/permission/client/client.go b/permission/client/client.go index bfe89153bc..2f9d32d5c4 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -63,3 +63,70 @@ func (c *Client) UpdateUserPermissions(ctx context.Context, sharerUserID string, return err } + +func (c *Client) PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (permission.Permissions, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if granteeUserID == "" { + return nil, errors.New("user id is missing") + } + + url := c.client.ConstructURL("access", "groups", granteeUserID) + result := permission.Permissions{} + if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { + if request.IsErrorResourceNotFound(err) { + return nil, request.ErrorUnauthorized() + } + return nil, err + } + + return result, nil +} + +func (c *Client) PermissionsGrantedByUser(ctx context.Context, sharerID string) (permission.Permissions, error) { + if ctx == nil { + return nil, errors.New("context is missing") + } + if sharerID == "" { + return nil, errors.New("user id is missing") + } + + url := c.client.ConstructURL("access", sharerID) + result := permission.Permissions{} + if err := c.client.RequestData(ctx, "GET", url, nil, nil, &result); err != nil { + if request.IsErrorResourceNotFound(err) { + return nil, request.ErrorUnauthorized() + } + return nil, err + } + + return result, nil +} + +func (c *Client) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + fromTo, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + if len(fromTo) > 0 { + return true, nil + } + toFrom, err := c.GetUserPermissions(ctx, grantorUserID, granteeUserID) + if err != nil { + return false, err + } + if len(toFrom) > 0 { + return true, nil + } + return false, nil +} + +func (c *Client) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + perms, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + _, ok := perms[permission.Custodian] + return ok, nil +} diff --git a/permission/client_mock.go b/permission/client_mock.go new file mode 100644 index 0000000000..937e814e4a --- /dev/null +++ b/permission/client_mock.go @@ -0,0 +1,130 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/permission (interfaces: Client) +// +// Generated by this command: +// +// mockgen -build_flags=--mod=mod -destination=./client_mock.go -package=permission . Client +// + +// Package permission is a generated GoMock package. +package permission + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// GetUserPermissions mocks base method. +func (m *MockClient) GetUserPermissions(ctx context.Context, requestUserID, targetUserID string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPermissions", ctx, requestUserID, targetUserID) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPermissions indicates an expected call of GetUserPermissions. +func (mr *MockClientMockRecorder) GetUserPermissions(ctx, requestUserID, targetUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPermissions", reflect.TypeOf((*MockClient)(nil).GetUserPermissions), ctx, requestUserID, targetUserID) +} + +// HasCustodianPermissions mocks base method. +func (m *MockClient) HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasCustodianPermissions", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasCustodianPermissions indicates an expected call of HasCustodianPermissions. +func (mr *MockClientMockRecorder) HasCustodianPermissions(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCustodianPermissions", reflect.TypeOf((*MockClient)(nil).HasCustodianPermissions), ctx, granteeUserID, grantorUserID) +} + +// PermissionsGrantedByUser mocks base method. +func (m *MockClient) PermissionsGrantedByUser(ctx context.Context, sharerID string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionsGrantedByUser", ctx, sharerID) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermissionsGrantedByUser indicates an expected call of PermissionsGrantedByUser. +func (mr *MockClientMockRecorder) PermissionsGrantedByUser(ctx, sharerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionsGrantedByUser", reflect.TypeOf((*MockClient)(nil).PermissionsGrantedByUser), ctx, sharerID) +} + +// PermissionsGrantedToUser mocks base method. +func (m *MockClient) PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PermissionsGrantedToUser", ctx, granteeUserID) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PermissionsGrantedToUser indicates an expected call of PermissionsGrantedToUser. +func (mr *MockClientMockRecorder) PermissionsGrantedToUser(ctx, granteeUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PermissionsGrantedToUser", reflect.TypeOf((*MockClient)(nil).PermissionsGrantedToUser), ctx, granteeUserID) +} + +// UpdateUserPermissions mocks base method. +func (m *MockClient) UpdateUserPermissions(ctx context.Context, requestUserID, targetUserID string, permissions Permissions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserPermissions", ctx, requestUserID, targetUserID, permissions) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserPermissions indicates an expected call of UpdateUserPermissions. +func (mr *MockClientMockRecorder) UpdateUserPermissions(ctx, requestUserID, targetUserID, permissions any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPermissions", reflect.TypeOf((*MockClient)(nil).UpdateUserPermissions), ctx, requestUserID, targetUserID, permissions) +} + +// UsersHaveSharingRelationship mocks base method. +func (m *MockClient) UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsersHaveSharingRelationship", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UsersHaveSharingRelationship indicates an expected call of UsersHaveSharingRelationship. +func (mr *MockClientMockRecorder) UsersHaveSharingRelationship(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsersHaveSharingRelationship", reflect.TypeOf((*MockClient)(nil).UsersHaveSharingRelationship), ctx, granteeUserID, grantorUserID) +} diff --git a/permission/permission.go b/permission/permission.go index d4c93addac..03acf6d6eb 100644 --- a/permission/permission.go +++ b/permission/permission.go @@ -5,6 +5,16 @@ import ( ) type Permission map[string]interface{} + +// Permissions are permissions that are keyed depending on the type of permissions that are being retrieved. +// +// If it is a one to one user to user permission check, then it is keyed by permssion type (Follow, Custodian, etc): +// +// Permissions{"follow": struct{}{}, "upload": struct{}{}} +// +// If it is a grouped set of permissions, it is keyed by userId: +// +// Permissions{"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa":{"root":{}},"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb":{"note":{},"upload":{},"view":{}}} type Permissions map[string]Permission const ( @@ -15,9 +25,20 @@ const ( Write = "upload" ) +//go:generate mockgen -build_flags=--mod=mod -destination=./client_mock.go -package=permission . Client type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) UpdateUserPermissions(ctx context.Context, requestUserID string, targetUserID string, permissions Permissions) error + // PermissionsGrantedToUser returns permissions that have been shared with + // granteeUserID. It is keyed by the user that has shared something with + // granteeUserID. It includes the user themself. + PermissionsGrantedToUser(ctx context.Context, granteeUserID string) (Permissions, error) + // PermissionsGrantedByUser returns permissions that the user with id + // sharerID has shared with others, keyed by user id. It includes the user + // themself. + PermissionsGrantedByUser(ctx context.Context, sharerID string) (Permissions, error) + UsersHaveSharingRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) + HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) } func FixOwnerPermissions(permissions Permissions) Permissions { @@ -75,3 +96,17 @@ func HasExplicitWritePermissions(ctx context.Context, c Client, granteeUserID, g } return false, nil } + +func (p Permission) Has(permissionType string) bool { + _, exists := p[permissionType] + return exists +} + +func (p Permission) HasAny(permissionTypes ...string) bool { + for _, perm := range permissionTypes { + if p.Has(perm) { + return true + } + } + return false +} diff --git a/prescription/application/test/mock.go b/prescription/application/test/mock.go new file mode 100644 index 0000000000..7d3577308b --- /dev/null +++ b/prescription/application/test/mock.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./mailer.go +// +// Generated by this command: +// +// mockgen --build_flags=--mod=mod -source=./mailer.go -destination=./mock.go -package test MockMailer +// + +// Package test is a generated GoMock package. +package test + +import ( + context "context" + reflect "reflect" + + events "github.com/tidepool-org/go-common/events" + gomock "go.uber.org/mock/gomock" +) + +// MockMockMailer is a mock of MockMailer interface. +type MockMockMailer struct { + ctrl *gomock.Controller + recorder *MockMockMailerMockRecorder + isgomock struct{} +} + +// MockMockMailerMockRecorder is the mock recorder for MockMockMailer. +type MockMockMailerMockRecorder struct { + mock *MockMockMailer +} + +// NewMockMockMailer creates a new mock instance. +func NewMockMockMailer(ctrl *gomock.Controller) *MockMockMailer { + mock := &MockMockMailer{ctrl: ctrl} + mock.recorder = &MockMockMailerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMockMailer) EXPECT() *MockMockMailerMockRecorder { + return m.recorder +} + +// SendEmailTemplate mocks base method. +func (m *MockMockMailer) SendEmailTemplate(arg0 context.Context, arg1 events.SendEmailTemplateEvent) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendEmailTemplate", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendEmailTemplate indicates an expected call of SendEmailTemplate. +func (mr *MockMockMailerMockRecorder) SendEmailTemplate(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendEmailTemplate", reflect.TypeOf((*MockMockMailer)(nil).SendEmailTemplate), arg0, arg1) +} diff --git a/store/structured/mongo/config.go b/store/structured/mongo/config.go index a8ab8ae012..217bd3c23b 100644 --- a/store/structured/mongo/config.go +++ b/store/structured/mongo/config.go @@ -72,7 +72,11 @@ func (c *Config) AsConnectionString() string { } func (c *Config) Load() error { - return envconfig.Process("", c) + return c.LoadPrefix("") +} + +func (c *Config) LoadPrefix(prefix string) error { + return envconfig.Process(prefix, c) } func (c *Config) SetDatabaseFromReporter(configReporter platformConfig.Reporter) error { diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go new file mode 100644 index 0000000000..66c3d54f53 --- /dev/null +++ b/user/fallback_user_accessor.go @@ -0,0 +1,113 @@ +package user + +import ( + "context" + "errors" + "time" +) + +// FallbackLegacyUserAccessor acts as an intermediary between seagulls profile +// and the new keycloak profile. This is because prior and during migration, +// some profiles may be still in seagull. As such, FallbackLegacyUserAccessor +// will first try to retrieve from seagull. If the profile is migrated or +// doesn't exist in seagull, then it will refer to keycloak / +// FallbackLegacyUserAccessor.accessor +type FallbackLegacyUserAccessor struct { + seagullLegacyAccessor LegacyProfileAccessor + accessor ProfileAccessor + roleGetter RoleGetter +} + +func NewFallbackLegacyUserAccessor(seagullAccessor LegacyProfileAccessor, accessor ProfileAccessor, roleGetter RoleGetter) *FallbackLegacyUserAccessor { + return &FallbackLegacyUserAccessor{ + seagullLegacyAccessor: seagullAccessor, + accessor: accessor, + roleGetter: roleGetter, + } +} + +func (f *FallbackLegacyUserAccessor) FindLegacyUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + profile, _, err := f.findLegacyUserProfile(ctx, userID) + return profile, err +} + +func (f *FallbackLegacyUserAccessor) findLegacyUserProfile(ctx context.Context, userID string) (profile *LegacyUserProfile, retrievedFromSeagull bool, err error) { + seagullProfile, err := f.seagullLegacyAccessor.FindLegacyUserProfile(ctx, userID) + // A not found error is OK to proceed as it may still exist in keycloak. Any + // other errors are unexpected. + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return nil, true, err + } + + // If a profile migration to keycloak is in progress or has recently failed, + // return the current profile from seagull if it exists instead of waiting. + if seagullProfile != nil && !IsMigrationCompleted(seagullProfile.MigrationStatus) { + return seagullProfile, true, nil + } + + profile, err = f.accessor.FindLegacyUserProfile(ctx, userID) + if err != nil { + return nil, false, err + } + if profile == nil { + return nil, false, ErrUserProfileNotFound + } + return profile, false, nil +} + +func (f *FallbackLegacyUserAccessor) UpdateLegacyUserProfile(ctx context.Context, id string, profile *LegacyUserProfile) error { + // retry any updates in case a migration happens sometime during this call - + // a migration should not take more than a few seconds so this is acceptable + // IMO. + arbritraryRetryLimit := 3 + var err error + for i := range arbritraryRetryLimit { + err = f.upsertLegacyUserProfile(ctx, id, profile) + if errors.Is(err, ErrUserProfileMigrationInProgress) { + time.Sleep(time.Second * time.Duration(i+1)) + continue + } + if err != nil { + return err + } + break + } + return err +} + +func (f *FallbackLegacyUserAccessor) UpdateUserProfile(ctx context.Context, userID string, profile *Profile) error { + legacyProfile, retrievedFromSeagull, err := f.findLegacyUserProfile(ctx, userID) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return err + } + + // This is only meant to be called for migrated profiles so it will return an error if the profile exists unmigrated in seagull + if legacyProfile != nil && retrievedFromSeagull && legacyProfile.MigrationStatus == MigrationUnmigrated { + return ErrProfileNotMigrated + } + + return f.accessor.UpdateUserProfile(ctx, userID, profile) +} + +func (f *FallbackLegacyUserAccessor) upsertLegacyUserProfile(ctx context.Context, userID string, update *LegacyUserProfile) error { + existingProfile, retrievedFromSeagull, err := f.findLegacyUserProfile(ctx, userID) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return err + } + + // Any unmigrated profile that exist in seagull should be returned to the + // user immediately. If the profile is currently being migrated, + // ErrUserProfileMigrationInProgress will be returned to the client to re-try + // their update as it is not expected for a migration to take more than a few + // seconds. There is no preemptive attempt to migrate the profile on access + // to avoid possibly migrating the profile the same time as the migrator is + // running. + if existingProfile != nil && retrievedFromSeagull && existingProfile.MigrationStatus == MigrationUnmigrated { + return f.seagullLegacyAccessor.UpdateLegacyUserProfile(ctx, userID, update) + } + + // If we've reached this point, the profile has either been migrated to + // keycloak OR it was created AFTER the release of keycloak profiles or it + // just doesn't exist so upsert the profile into the non-legacy ProfileAccessor + return f.accessor.UpdateLegacyUserProfile(ctx, userID, update) +} diff --git a/user/keycloak/client.go b/user/keycloak/client.go new file mode 100644 index 0000000000..31eaf8eaa3 --- /dev/null +++ b/user/keycloak/client.go @@ -0,0 +1,494 @@ +package keycloak + +import ( + "context" + "fmt" + "maps" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/Nerzal/gocloak/v13" + "github.com/Nerzal/gocloak/v13/pkg/jwx" + "github.com/go-resty/resty/v2" + "github.com/kelseyhightower/envconfig" + "golang.org/x/oauth2" + + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/user" +) + +const ( + masterRealm = "master" + termsAcceptedAttribute = "terms_and_conditions" + + serverRole = "backend_service" + timestampFormat = "2006-01-02T15:04:05-07:00" +) + +type KeycloakConfig struct { + ClientID string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_ID" required:"true"` + ClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_CLIENT_SECRET" required:"true"` + LongLivedClientID string `envconfig:"TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_ID" required:"true"` + LongLivedClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_LONG_LIVED_CLIENT_SECRET" required:"true"` + BackendClientID string `envconfig:"TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_ID" required:"true"` + BackendClientSecret string `envconfig:"TIDEPOOL_KEYCLOAK_BACKEND_CLIENT_SECRET" required:"true"` + BaseUrl string `envconfig:"TIDEPOOL_KEYCLOAK_BASE_URL" required:"true"` + Realm string `envconfig:"TIDEPOOL_KEYCLOAK_REALM" required:"true"` + AdminUsername string `envconfig:"TIDEPOOL_KEYCLOAK_ADMIN_USERNAME" required:"true"` + AdminPassword string `envconfig:"TIDEPOOL_KEYCLOAK_ADMIN_PASSWORD" required:"true"` +} + +func (c *KeycloakConfig) FromEnv() error { + return envconfig.Process("", c) +} + +type keycloakClient struct { + cfg *KeycloakConfig + adminToken *oauth2.Token + adminTokenRefreshExpires time.Time + keycloak *gocloak.GoCloak + adminTokenLock *sync.RWMutex +} + +func newKeycloakClient(config *KeycloakConfig) *keycloakClient { + return &keycloakClient{ + cfg: config, + keycloak: gocloak.NewClient(config.BaseUrl), + adminTokenLock: &sync.RWMutex{}, + } +} + +func (c *keycloakClient) Login(ctx context.Context, username, password string) (*oauth2.Token, error) { + return c.doLogin(ctx, c.cfg.ClientID, c.cfg.ClientSecret, username, password) +} + +func (c *keycloakClient) LoginLongLived(ctx context.Context, username, password string) (*oauth2.Token, error) { + return c.doLogin(ctx, c.cfg.LongLivedClientID, c.cfg.LongLivedClientSecret, username, password) +} + +func (c *keycloakClient) doLogin(ctx context.Context, clientId, clientSecret, username, password string) (*oauth2.Token, error) { + jwt, err := c.keycloak.Login( + ctx, + clientId, + clientSecret, + c.cfg.Realm, + username, + password, + ) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *keycloakClient) GetBackendServiceToken(ctx context.Context) (*oauth2.Token, error) { + jwt, err := c.keycloak.LoginClient(ctx, c.cfg.BackendClientID, c.cfg.BackendClientSecret, c.cfg.Realm) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *keycloakClient) jwtToAccessToken(jwt *gocloak.JWT) *oauth2.Token { + if jwt == nil { + return nil + } + return (&oauth2.Token{ + AccessToken: jwt.AccessToken, + TokenType: jwt.TokenType, + RefreshToken: jwt.RefreshToken, + Expiry: time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second), + }).WithExtra(map[string]any{ + "refresh_expires_in": jwt.RefreshExpiresIn, + }) +} + +func (c *keycloakClient) RevokeToken(ctx context.Context, token oauth2.Token) error { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + return c.keycloak.Logout( + ctx, + clientId, + clientSecret, + c.cfg.Realm, + token.RefreshToken, + ) +} + +func (c *keycloakClient) RefreshToken(ctx context.Context, token oauth2.Token) (*oauth2.Token, error) { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + + jwt, err := c.keycloak.RefreshToken( + ctx, + token.RefreshToken, + clientId, + clientSecret, + c.cfg.Realm, + ) + if err != nil { + return nil, err + } + return c.jwtToAccessToken(jwt), nil +} + +func (c *keycloakClient) GetUserById(ctx context.Context, id string) (*user.User, error) { + if id == "" { + return nil, nil + } + + users, err := c.FindUsersWithIds(ctx, []string{id}) + if err != nil || len(users) == 0 { + return nil, err + } + + return users[0], nil +} + +func (c *keycloakClient) GetUserByEmail(ctx context.Context, email string) (*user.User, error) { + if email == "" { + return nil, nil + } + token, err := c.getAdminToken(ctx) + if err != nil { + return nil, err + } + + users, err := c.keycloak.GetUsers(ctx, token.AccessToken, c.cfg.Realm, gocloak.GetUsersParams{ + Email: &email, + Exact: gocloak.BoolP(true), + }) + if err != nil || len(users) == 0 { + return nil, err + } + + return c.GetUserById(ctx, *users[0].ID) +} + +func (c *keycloakClient) UpdateUser(ctx context.Context, u *user.User) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + gocloakUser := gocloak.User{ + ID: u.UserID, + Username: u.Username, + Enabled: &u.Enabled, + EmailVerified: u.EmailVerified, + Email: u.Username, + } + + attrs := map[string][]string{} + maps.Copy(attrs, u.Attributes) + if terms := pointer.ToString(u.TermsAccepted); terms != "" { + attrs[termsAcceptedAttribute] = []string{terms} + } + + if u.Profile != nil { + maps.Copy(attrs, u.Profile.ToAttributes()) + } + + gocloakUser.Attributes = &attrs + if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { + return err + } + if err := c.updateRolesForUser(ctx, u); err != nil { + return err + } + return nil +} + +func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *user.Profile) error { + u, err := c.GetUserById(ctx, id) + if err != nil { + return err + } + if u == nil { + return user.ErrUserNotFound + } + u.Profile = p + return c.UpdateUser(ctx, u) +} + +func (c *keycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*user.User, err error) { + const errMessage = "could not retrieve users by ids" + + token, err := c.getAdminToken(ctx) + if err != nil { + return nil, err + } + + var res []*gocloak.User + var errorResponse gocloak.HTTPErrorResponse + response, err := c.keycloak.RestyClient().R(). + SetContext(ctx). + SetError(&errorResponse). + SetAuthToken(token.AccessToken). + SetResult(&res). + SetQueryParam("ids", strings.Join(ids, ",")). + Get(c.getRealmURL(c.cfg.Realm, "tidepool-admin", "users")) + + err = checkForError(response, err, errMessage) + if err != nil { + return nil, err + } + + users = make([]*user.User, len(res)) + for i, u := range res { + users[i] = newUserFromGocloakUser(u) + } + + return users, nil +} + +func (c *keycloakClient) DeleteUserSessions(ctx context.Context, id string) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + if err := c.keycloak.LogoutAllSessions(ctx, token.AccessToken, c.cfg.Realm, id); err != nil { + if aErr, ok := err.(*gocloak.APIError); ok && aErr.Code == http.StatusNotFound { + return nil + } + } + + return err +} + +func (c *keycloakClient) getRealmURL(realm string, path ...string) string { + path = append([]string{c.cfg.BaseUrl, "realms", realm}, path...) + return strings.Join(path, "/") +} + +func (c *keycloakClient) getAdminToken(ctx context.Context) (oauth2.Token, error) { + var err error + if c.adminTokenIsExpired() { + if err := c.loginAsAdmin(ctx); err != nil { + return oauth2.Token{}, err + } + } + + c.adminTokenLock.RLock() + defer c.adminTokenLock.RUnlock() + return *c.adminToken, err +} + +func (c *keycloakClient) loginAsAdmin(ctx context.Context) error { + jwt, err := c.keycloak.LoginAdmin( + ctx, + c.cfg.AdminUsername, + c.cfg.AdminPassword, + masterRealm, + ) + if err != nil { + return err + } + + c.adminTokenLock.Lock() + defer c.adminTokenLock.Unlock() + c.adminToken = c.jwtToAccessToken(jwt) + expiration := time.Now().Add(time.Duration(jwt.ExpiresIn)*time.Second - time.Second*5) // check if adding a small buffer to expire time to allow earlier refresh still results in a time in the future + if expiration.After(time.Now()) { + c.adminTokenRefreshExpires = expiration + } else { + c.adminTokenRefreshExpires = time.Now().Add(time.Duration(jwt.ExpiresIn) * time.Second) + } + return nil +} + +func (c *keycloakClient) adminTokenIsExpired() bool { + c.adminTokenLock.RLock() + defer c.adminTokenLock.RUnlock() + return c.adminToken == nil || time.Now().After(c.adminTokenRefreshExpires) +} + +func (c *keycloakClient) updateRolesForUser(ctx context.Context, u *user.User) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + userID := pointer.ToString(u.UserID) + + realmRoles, err := c.keycloak.GetRealmRoles(ctx, token.AccessToken, c.cfg.Realm, gocloak.GetRoleParams{ + Max: gocloak.IntP(1000), + }) + if err != nil { + return err + } + currentUserRoles, err := c.keycloak.GetRealmRolesByUserID(ctx, token.AccessToken, c.cfg.Realm, userID) + if err != nil { + return err + } + + var rolesToAdd []gocloak.Role + var rolesToDelete []gocloak.Role + + targetRoles := make(map[string]struct{}) + if u.Roles != nil && len(*u.Roles) > 0 { + for _, targetRoleName := range *u.Roles { + targetRoles[targetRoleName] = struct{}{} + } + } + + for targetRoleName := range targetRoles { + realmRole := getRealmRoleByName(realmRoles, targetRoleName) + if realmRole != nil { + rolesToAdd = append(rolesToAdd, *realmRole) + } + } + + if len(currentUserRoles) > 0 { + for _, currentRole := range currentUserRoles { + if currentRole == nil || currentRole.Name == nil || *currentRole.Name == "" { + continue + } + + if _, ok := targetRoles[*currentRole.Name]; !ok { + // Only remove roles managed by shoreline + if _, ok := user.ShorelineManagedRoles[*currentRole.Name]; ok { + rolesToDelete = append(rolesToDelete, *currentRole) + } + } + } + } + + if len(rolesToAdd) > 0 { + if err = c.keycloak.AddRealmRoleToUser(ctx, token.AccessToken, c.cfg.Realm, userID, rolesToAdd); err != nil { + return err + } + } + if len(rolesToDelete) > 0 { + if err = c.keycloak.DeleteRealmRoleFromUser(ctx, token.AccessToken, c.cfg.Realm, userID, rolesToDelete); err != nil { + return err + } + } + + return nil +} + +func (c *keycloakClient) GetRolesForUser(ctx context.Context, userID string) ([]string, error) { + token, err := c.getAdminToken(ctx) + if err != nil { + return nil, err + } + + realmRoles, err := c.keycloak.GetRealmRolesByUserID(ctx, token.AccessToken, c.cfg.Realm, userID) + if err != nil { + return nil, err + } + + roles := make([]string, 0, len(realmRoles)) + for _, role := range realmRoles { + if role == nil || strings.TrimSpace(pointer.ToString(role.Name)) == "" { + continue + } + roleName := strings.TrimSpace(pointer.ToString(role.Name)) + roles = append(roles, roleName) + } + + return roles, nil +} + +func (c *keycloakClient) getClientAndSecretFromToken(ctx context.Context, token oauth2.Token) (string, string) { + clientId := c.cfg.ClientID + clientSecret := c.cfg.ClientSecret + + customClaims := &jwx.Claims{} + _, err := c.keycloak.DecodeAccessTokenCustomClaims( + ctx, + token.AccessToken, + c.cfg.Realm, + customClaims, + ) + + if err == nil && customClaims.Azp == c.cfg.LongLivedClientID { + clientId = c.cfg.LongLivedClientID + clientSecret = c.cfg.LongLivedClientSecret + } + + return clientId, clientSecret +} + +func newUserFromGocloakUser(gocloakUser *gocloak.User) *user.User { + u := &user.User{ + UserID: gocloakUser.ID, + Username: gocloakUser.Username, + Roles: gocloakUser.RealmRoles, + EmailVerified: gocloakUser.EmailVerified, + Enabled: pointer.ToBool(gocloakUser.Enabled), + } + if gocloakUser.Attributes != nil { + attrs := *gocloakUser.Attributes + if termsAttrs, ok := attrs[termsAcceptedAttribute]; ok && len(termsAttrs) > 0 { + if ts, err := unixTimeStringToTimestamp(termsAttrs[0]); err == nil { + u.TermsAccepted = &ts + } + } + var roles []string + if gocloakUser.RealmRoles != nil { + roles = *gocloakUser.RealmRoles + } + if profile := user.ProfileFromAttributes(pointer.ToString(gocloakUser.Username), attrs, roles); profile != nil { + u.Profile = profile + } + u.Attributes = attrs + } + + return u +} + +func getRealmRoleByName(realmRoles []*gocloak.Role, name string) *gocloak.Role { + for _, realmRole := range realmRoles { + if realmRole.Name != nil && *realmRole.Name == name { + return realmRole + } + } + + return nil +} + +// checkForError Copied from gocloak - used for sending requests to custom endpoints +func checkForError(resp *resty.Response, err error, errMessage string) error { + if err != nil { + return &gocloak.APIError{ + Code: 0, + Message: fmt.Errorf("%w: %s", err, errMessage).Error(), + } + } + + if resp == nil { + return &gocloak.APIError{ + Message: "empty response", + } + } + + if resp.IsError() { + var msg string + + if e, ok := resp.Error().(*gocloak.HTTPErrorResponse); ok && e.NotEmpty() { + msg = fmt.Sprintf("%s: %s", resp.Status(), e) + } else { + msg = resp.Status() + } + + return &gocloak.APIError{ + Code: resp.StatusCode(), + Message: msg, + } + } + + return nil +} + +func unixTimeStringToTimestamp(unixString string) (timestamp string, err error) { + i, err := strconv.ParseInt(unixString, 10, 64) + if err != nil { + return + } + t := time.Unix(i, 0) + timestamp = t.Format(timestampFormat) + return +} diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go new file mode 100644 index 0000000000..6d1e53e8ac --- /dev/null +++ b/user/keycloak/user_accessor.go @@ -0,0 +1,67 @@ +package keycloak + +import ( + "context" + + "github.com/tidepool-org/platform/pointer" + user "github.com/tidepool-org/platform/user" +) + +type keycloakUserAccessor struct { + keycloakClient *keycloakClient +} + +func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { + return &keycloakUserAccessor{ + keycloakClient: newKeycloakClient(config), + } +} + +func (m *keycloakUserAccessor) Get(ctx context.Context, id string) (*user.User, error) { + if !user.IsValidUserID(id) { + return nil, user.ErrUserNotFound + } + + u, err := m.keycloakClient.GetUserById(ctx, id) + if err != nil { + return nil, err + } + if u == nil { + return nil, user.ErrUserNotFound + } + return u, nil +} + +func (m *keycloakUserAccessor) FindLegacyUserProfile(ctx context.Context, id string) (*user.LegacyUserProfile, error) { + u, err := m.Get(ctx, id) + if err != nil { + return nil, err + } + if u == nil || u.Profile == nil { + return nil, user.ErrUserProfileNotFound + } + return u.Profile.ToLegacyProfile(pointer.ToStringArray(u.Roles)), nil +} + +func (m *keycloakUserAccessor) Roles(ctx context.Context, userID string) ([]string, error) { + return m.keycloakClient.GetRolesForUser(ctx, userID) +} + +func (m *keycloakUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) (users []*user.User, err error) { + return m.keycloakClient.FindUsersWithIds(ctx, ids) +} + +func (m *keycloakUserAccessor) UpdateLegacyUserProfile(ctx context.Context, userID string, p *user.LegacyUserProfile) error { + roles, err := m.Roles(ctx, userID) + if err != nil { + return err + } + if !user.HasClinicOrClinicianRole(roles) && p.Clinic != nil { + p.Clinic = nil + } + return m.keycloakClient.UpdateUserProfile(ctx, userID, p.ToUserProfile()) +} + +func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userID string, p *user.Profile) error { + return m.keycloakClient.UpdateUserProfile(ctx, userID, p) +} diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go new file mode 100644 index 0000000000..ddcd01dcea --- /dev/null +++ b/user/legacy_raw_seagull_profile.go @@ -0,0 +1,148 @@ +package user + +import ( + "cmp" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/tidepool-org/platform/pointer" +) + +var ( + ErrSeagullMarshalValue = errors.New(`unable to encode seagull "value" to JSON`) + ErrSeagullUnmarshalValue = errors.New(`unable to decode seagull "value" from JSON`) +) + +// LegacySeagullDocument is the database model representation of the legacy +// seagull collection object. The value is a raw stringified JSON blob. +type LegacySeagullDocument struct { + UserID string `bson:"userId"` + Value string `bson:"value"` + + // The presence of these various migration markers indicate the migration + // status of a seagull profile into keycloak. A non nil MigrationStart and + // nil MigrationEnd indicates an inprogress migration UNLESS MigrationError + // is non empty, in which migration should be reattempted. + MigrationStart *time.Time `bson:"_migrationStart,omitempty"` + // The presence of migrationEnd means the profile is fully migrated and all reads / writes to a user profile should go through keycloak + MigrationEnd *time.Time `bson:"_migrationEnd,omitempty"` + MigrationError *string `bson:"_migrationError,omitempty"` + MigrationErrorTime *time.Time `bson:"_migrationErrorTime,omitempty"` +} + +// ToLegacyProfile returns an object that is suitable as a JSON response - ie, the profile is not just a stringified JSON blob. +func (doc *LegacySeagullDocument) ToLegacyProfile() (*LegacyUserProfile, error) { + valueObj, err := extractSeagullValue(doc.Value) + if err != nil { + return nil, err + } + // Unfortunately since the profile is embedded within the raw string and unmarshaled to a map[string]any, we will need Marshal and Unmarshal to our actual LegacyUserProfile object. + profileRaw, ok := valueObj["profile"].(map[string]any) + if !ok { + return nil, ErrUserProfileNotFound + } + var legacyProfile LegacyUserProfile + if err := MarshalThenUnmarshal(profileRaw, &legacyProfile); err != nil { + return nil, err + } + + // Add some default names if it is an empty name for the fake child or parent of them + isFakeChild := legacyProfile.Patient != nil && legacyProfile.Patient.IsOtherPerson + if isFakeChild { + // Some fake child accounts have profiles w/ an empty patient fullName or profile fullName (but not both). + // In this case, use the non empty name for both. + parentName := legacyProfile.FullName + childName := pointer.ToString(legacyProfile.Patient.FullName) + var fullName string + if parentName == "" || childName == "" { + fullName = cmp.Or(parentName, childName) + legacyProfile.Patient.FullName = &fullName + legacyProfile.FullName = fullName + } + } + + legacyProfile.MigrationStatus = doc.MigrationStatus() + return &legacyProfile, nil +} + +func (doc *LegacySeagullDocument) RawValue() (valueAsMap map[string]any, err error) { + return extractSeagullValue(doc.Value) +} + +// SetRawValueProfile updates the document's jsonified Value field to contain a "profile" field with the given profile +func (doc *LegacySeagullDocument) SetRawValueProfile(profile map[string]any) error { + valueObj, err := doc.RawValue() + // If there was an error, just make a new field "value" value. + if err != nil { + valueObj = map[string]any{} + } + valueObj["profile"] = profile + bytes, err := json.Marshal(valueObj) + if err != nil { + return fmt.Errorf(`%w: %w`, ErrSeagullMarshalValue, err) + } + doc.Value = string(bytes) + return nil +} + +// extractSeagullValue unmarshals the jsonified string field "value" in the +// seagull collection to a map[string]any - the reason the fields aren't +// explicitly defined is because there is / was no defined schema at the +// time for seagull, so we should preserve these fields. +func extractSeagullValue(valueRaw string) (valueAsMap map[string]any, err error) { + var value map[string]any + if err := json.Unmarshal([]byte(valueRaw), &value); err != nil { + return nil, fmt.Errorf(`%w: %w`, ErrSeagullUnmarshalValue, err) + } + return value, nil +} + +// AddProfileToSeagullValue takes a legacy profile and adds it to an +// existing valueObj (the unmarshaled "value" of the seagull +// collection"), then returns the marshaled version of it. It returns +// this new object as a raw string to be compatible with the seagull +// collection. This is done to preserve any non profile fields that were +// stored in the "value" field +func AddProfileToSeagullValue(valueRaw string, profile *LegacyUserProfile) (updatedValueRaw string, err error) { + valueObj, err := extractSeagullValue(valueRaw) + // If there was an error, just make a new field "value" value. + if err != nil { + valueObj = map[string]any{} + } + valueObj["profile"] = profile + bytes, err := json.Marshal(valueObj) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// MarshalThenUnmarshal marshal's src into JSON, then Unmarshals that +// JSON into dst. This is useful if src has some fields fields common to +// dst but are defined explicitly or in the same way. +func MarshalThenUnmarshal(src any, dst *LegacyUserProfile) error { + bytes, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(bytes, dst) +} + +func (doc *LegacySeagullDocument) MigrationStatus() migrationStatus { + if doc.MigrationStart != nil && doc.MigrationEnd != nil { + return MigrationCompleted + } + if doc.MigrationStart != nil && doc.MigrationEnd == nil && doc.MigrationError == nil { + return MigrationInProgress + } + if doc.MigrationStart != nil && doc.MigrationError != nil { + return MigrationError + } + return MigrationUnmigrated +} + +func (doc *LegacySeagullDocument) IsMigrating() bool { + return doc.MigrationStatus() != MigrationUnmigrated +} diff --git a/user/profile.go b/user/profile.go new file mode 100644 index 0000000000..93be76d0b2 --- /dev/null +++ b/user/profile.go @@ -0,0 +1,561 @@ +package user + +import ( + "cmp" + "encoding/json" + "regexp" + "slices" + "strings" + "time" + + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/structure" +) + +type migrationStatus int + +var ( + nonLetters = regexp.MustCompile(`[^A-Za-z]`) +) + +const ( + MigrationUnmigrated migrationStatus = iota + MigrationCompleted + MigrationInProgress + MigrationError + + MaxProfileFieldLen = 255 +) + +func IsMigrationCompleted(status migrationStatus) bool { + return status == MigrationCompleted +} + +const ( + DiabetesTypeType1 = "type1" + DiabetesTypeType2 = "type2" + DiabetesTypeGestational = "gestational" + DiabetesTypeLada = "lada" + DiabetesTypeOther = "other" + DiabetesTypePrediabetes = "prediabetes" + DiabetesTypeMody = "mody" +) + +var ( + DiabetesTypes = []string{ + DiabetesTypeType1, + DiabetesTypeType2, + DiabetesTypeGestational, + DiabetesTypeLada, + DiabetesTypeOther, + DiabetesTypePrediabetes, + DiabetesTypeMody, + } +) + +// Date is a string of type YYYY-mm-dd, the reason this isn't just a type definition +// of a time.Time is to ignore timezones when marshaling. +type Date string + +// Profile represents the modifiable user profile attributes of a user. +type Profile struct { + FullName string `json:"fullName,omitempty"` // Name of the patient, fake child, or clinician + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` + MRN string `json:"mrn,omitempty"` + BiologicalSex string `json:"biologicalSex,omitempty"` + + Custodian *Custodian `json:"custodian,omitempty"` + // The PRESENCE of a clinic object in a profile is used by blip to determine which page to show so this needs to be returned in the response. + // There are clinicians/legacy clinics with completely empty values within the clinic object but are still clinicians/clinics. + Clinic *ClinicProfile `json:"clinic,omitempty"` +} + +type ClinicProfile struct { + Name *string `json:"name,omitempty"` // Refers to the name of the clinic, not clinician + Role *string `json:"role,omitempty"` + Telephone *string `json:"telephone,omitempty"` + NPI *string `json:"npi,omitempty"` +} + +type Custodian struct { + FullName string `json:"fullName"` +} + +func HasPatientRole(roles []string) bool { + return slices.Contains(roles, RolePatient) +} + +func HasClinicOrClinicianRole(roles []string) bool { + return slices.Contains(roles, RoleClinician) || slices.Contains(roles, RoleClinic) +} + +// IsPatientProfile returns true if the profile is associated with a patient - note that this is not mutually exclusive w/ a clinician, as some users have both +func (up *Profile) IsPatientProfile(roles []string) bool { + return HasPatientRole(roles) || up.hasPatientFields() || !HasClinicOrClinicianRole(roles) +} + +func (up *Profile) hasPatientFields() bool { + return up.DiagnosisDate != "" || up.DiagnosisType != "" || len(up.TargetDevices) > 0 || up.MRN != "" || up.About != "" || up.BiologicalSex != "" || up.Birthday != "" || up.Custodian != nil +} + +// IsClinicianProfile returns true if the profile is associated with a clinician - note that this is not mutually exclusive w/ a patient, as some users have both +func (up *Profile) IsClinicianProfile(roles []string) bool { + return up.Clinic != nil || HasClinicOrClinicianRole(roles) +} + +func (up *Profile) ToLegacyProfile(roles []string) *LegacyUserProfile { + legacyProfile := &LegacyUserProfile{ + FullName: up.FullName, + MigrationStatus: MigrationCompleted, // If we have a non legacy UserProfile, then that means the legacy version has been migrated from seagull (or it never existed which is equivalent for the new user profile purposes) + } + + if up.IsClinicianProfile(roles) { + legacyProfile.Clinic = up.Clinic + // Frontend uses the PRESENCE of a clinic object in some of its logic to + // determine what pages to show so if this is a clinician so if there are + // no actual clinician fields in the profile (No clinician role (such as + // clinic_manager, endocrinologist, etc), npi, telephone etc), make an + // empty, non-nil object. + if legacyProfile.Clinic == nil { + legacyProfile.Clinic = &ClinicProfile{} + } + } + + if up.IsPatientProfile(roles) { + legacyProfile.Patient = &LegacyPatientProfile{ + Birthday: up.Birthday, + DiagnosisDate: up.DiagnosisDate, + DiagnosisType: up.DiagnosisType, + TargetDevices: up.TargetDevices, + TargetTimezone: up.TargetTimezone, + About: up.About, + MRN: up.MRN, + BiologicalSex: up.BiologicalSex, + } + } + // only custodiaL fake child accounts have Patient.FullName set + if up.Custodian != nil { + legacyProfile.Patient.IsOtherPerson = true + // Handle case where Custodian user (contains fake child) and one of the FullName's is empty. + legacyProfile.FullName = cmp.Or(up.Custodian.FullName, up.FullName) + legacyProfile.Patient.FullName = pointer.FromString(cmp.Or(up.FullName, up.Custodian.FullName)) + } + return legacyProfile +} + +func (p *LegacyUserProfile) ToUserProfile() *Profile { + up := &Profile{ + FullName: p.FullName, + Clinic: p.Clinic, + } + + if p.Patient != nil { + // The new profiles FullName refer to the true "owner" of the profile - which + // may be the "fake child" so set it to the FullName within the Patient Object if it exists. + up.FullName = cmp.Or(pointer.ToString(p.Patient.FullName), p.FullName) + // Only users with isOtherPerson set has a patient.fullName field set so these users + // also have a custodian + if p.Patient.IsOtherPerson { + // Handle the few cases where one of either the fake child fullName or the profile fullName is empty (neither are both empty) + // The custodian's name would be the the profile.fullName field in the legacy + // format. But there are few cases where it's empty so set it to profile.patient.fullName if it exists + up.Custodian = &Custodian{ + FullName: cmp.Or(p.FullName, pointer.ToString(p.Patient.FullName)), + } + } + up.Birthday = p.Patient.Birthday + up.DiagnosisDate = p.Patient.DiagnosisDate + up.DiagnosisType = p.Patient.DiagnosisType + up.TargetDevices = p.Patient.TargetDevices + up.TargetTimezone = p.Patient.TargetTimezone + up.About = p.Patient.About + up.MRN = p.Patient.MRN + up.BiologicalSex = p.Patient.BiologicalSex + } + if p.Clinic != nil { + up.Clinic = &ClinicProfile{ + Name: pointer.CloneString(p.Clinic.Name), + Role: pointer.CloneString(p.Clinic.Role), + Telephone: pointer.CloneString(p.Clinic.Telephone), + NPI: pointer.CloneString(p.Clinic.NPI), + } + } + return up + +} + +func (p *Profile) Sanitize() { + // Clear out patient fields + p.Birthday = "" + p.DiagnosisDate = "" + p.DiagnosisType = "" + p.TargetDevices = nil + p.TargetTimezone = "" + p.About = "" + p.MRN = "" + p.BiologicalSex = "" +} + +// LegacyUserProfile represents the old seagull format for a profile. +type LegacyUserProfile struct { + FullName string `json:"fullName,omitempty"` // string pointer because some old profiles have empty string as full name + Patient *LegacyPatientProfile `json:"patient,omitempty"` + Clinic *ClinicProfile `json:"clinic,omitempty"` + MigrationStatus migrationStatus `json:"-"` +} + +type LegacyPatientProfile struct { + FullName *string `json:"fullName,omitempty"` // This is only non-empty if the user is also a fake child (has the patient.isOtherPerson field set - there are cases where it is an empty string but the field exists) + Birthday Date `json:"birthday,omitempty"` + DiagnosisDate Date `json:"diagnosisDate,omitempty"` + DiagnosisType string `json:"diagnosisType,omitempty"` + TargetDevices []string `json:"targetDevices,omitempty"` + TargetTimezone string `json:"targetTimezone,omitempty"` + About string `json:"about,omitempty"` + IsOtherPerson jsonBool `json:"isOtherPerson,omitempty"` + MRN string `json:"mrn,omitempty"` + BiologicalSex string `json:"biologicalSex,omitempty"` +} + +func (l *LegacyPatientProfile) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + + // Handle some old seagull fields that contained an empty string for the patient field, return an empty object in that case + dataStr := string(data) + if dataStr == `""` { + return nil + } + + // Create a new type definition w/ same underlying type as + // LegacyPatientProfile so we can use the "default" UnmarshalJSON of + // LegacyPatientProfile as if it didn't implement json.Unmarshaler (to + // prevent an infinite loop) + type tempType LegacyPatientProfile + return json.Unmarshal(data, (*tempType)(l)) +} + +// jsonBool is a bool type that can be marshaled from string fields - this is only in support of legacy seagull profiles. +// Once all seagull profiles have been migrated over, LegacyProfile along w/ jsonBool will be removed +type jsonBool bool + +func (b *jsonBool) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + return nil + } + dataStr := string(data) + boolStr := strings.ToLower(nonLetters.ReplaceAllString(dataStr, "")) + if boolStr == "true" { + *b = true + } else { + *b = false + } + return nil +} + +func (up *Profile) ToAttributes() map[string][]string { + attributes := map[string][]string{} + + if up.FullName != "" { + addAttribute(attributes, "full_name", up.FullName) + } + if up.Custodian != nil && up.Custodian.FullName != "" { + addAttribute(attributes, "custodian_full_name", up.Custodian.FullName) + // The "has_custodian" attribute is only added so that filtering on users is simpler via the keycloak API - because + // there is a way to filter by custom attribute values but not by the presence of one. + addAttribute(attributes, "has_custodian", "true") + } + if string(up.Birthday) != "" { + addAttribute(attributes, "birthday", string(up.Birthday)) + } + if string(up.DiagnosisDate) != "" { + addAttribute(attributes, "diagnosis_date", string(up.DiagnosisDate)) + } + if up.DiagnosisType != "" { + addAttribute(attributes, "diagnosis_type", up.DiagnosisType) + } + addAttributes(attributes, "target_devices", up.TargetDevices...) + if up.TargetTimezone != "" { + addAttribute(attributes, "target_timezone", up.TargetTimezone) + } + if up.About != "" { + addAttribute(attributes, "about", up.About) + } + if up.MRN != "" { + addAttribute(attributes, "mrn", up.MRN) + } + if up.BiologicalSex != "" { + addAttribute(attributes, "biological_sex", up.BiologicalSex) + } + + if up.Clinic != nil { + if val := pointer.ToString(up.Clinic.Name); val != "" { + addAttribute(attributes, "clinic_name", val) + } + if val := pointer.ToString(up.Clinic.Role); val != "" { + addAttribute(attributes, "clinic_role", val) + } + if val := pointer.ToString(up.Clinic.Telephone); val != "" { + addAttribute(attributes, "clinic_telephone", val) + } + if val := pointer.ToString(up.Clinic.NPI); val != "" { + addAttribute(attributes, "clinic_npi", val) + } + } + + return attributes +} + +// ProfileFromAttributes returns a [Profile] if there exists at least one +// profile attribute in the supplied attributes. Otherwise it returns nil. +func ProfileFromAttributes(username string, attributes map[string][]string, roles []string) *Profile { + up := &Profile{} + foundAnyProfileAttr := false + if val := getAttribute(attributes, "full_name"); val != "" { + up.FullName = val + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "custodian_full_name"); val != "" { + up.Custodian = &Custodian{ + FullName: val, + } + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "birthday"); val != "" { + up.Birthday = Date(val) + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "diagnosis_date"); val != "" { + up.DiagnosisDate = Date(val) + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "diagnosis_type"); val != "" { + up.DiagnosisType = val + foundAnyProfileAttr = true + } + if vals := getAttributes(attributes, "target_devices"); len(vals) > 0 { + up.TargetDevices = vals + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "target_timezone"); val != "" { + up.TargetTimezone = val + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "about"); val != "" { + up.About = val + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "mrn"); val != "" { + up.MRN = val + foundAnyProfileAttr = true + } + if val := getAttribute(attributes, "biological_sex"); val != "" { + up.BiologicalSex = val + foundAnyProfileAttr = true + } + + var clinicProfile ClinicProfile + // A clinic may have all empty fields but still needs a clinic object + // returned so check both the presence of the clinic / clinician role and + // individual clinic properties - It may be enough to just check the roles + hasClinicProfile := HasClinicOrClinicianRole(roles) + if val := getAttribute(attributes, "clinic_name"); val != "" { + clinicProfile.Name = pointer.FromString(val) + hasClinicProfile = true + } + if val := getAttribute(attributes, "clinic_role"); val != "" { + clinicProfile.Role = pointer.FromString(val) + hasClinicProfile = true + } + if val := getAttribute(attributes, "clinic_telephone"); val != "" { + clinicProfile.Telephone = pointer.FromString(val) + hasClinicProfile = true + } + if val := getAttribute(attributes, "clinic_npi"); val != "" { + clinicProfile.NPI = pointer.FromString(val) + hasClinicProfile = true + } + if hasClinicProfile { + up.Clinic = &clinicProfile + foundAnyProfileAttr = true + } + + if foundAnyProfileAttr { + return up + } + return nil +} + +func addAttribute(attributes map[string][]string, attribute, value string) (ok bool) { + if !containsAttribute(attributes, attribute, value) { + attributes[attribute] = append(attributes[attribute], value) + return true + } + return false +} + +func getAttribute(attributes map[string][]string, attribute string) string { + if len(attributes[attribute]) > 0 { + return attributes[attribute][0] + } + return "" +} + +func getAttributes(attributes map[string][]string, attribute string) []string { + return attributes[attribute] +} + +func addAttributes(attributes map[string][]string, attribute string, values ...string) (ok bool) { + for _, value := range values { + if addAttribute(attributes, attribute, value) { + ok = true + } + } + return true +} + +func containsAttribute(attributes map[string][]string, attribute, value string) bool { + for key, vals := range attributes { + if key == attribute && slices.Contains(vals, value) { + return true + } + } + return false +} + +func containsAnyAttributeKeys(attributes map[string][]string, keys ...string) bool { + for key, vals := range attributes { + if len(vals) > 0 && slices.Contains(keys, key) { + return true + } + } + return false +} + +func (d *Date) Validate(v structure.Validator) { + if d == nil || *d == "" { + return + } + str := string(*d) + v.String("date", &str).AsTime(time.DateOnly) +} + +func (d *Date) Normalize(normalizer structure.Normalizer) { + if d == nil || *d == "" { + return + } + *d = Date(strings.TrimSpace(string(*d))) +} + +func (up *Profile) Validate(v structure.Validator) { + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("diagnosisType", &up.DiagnosisType).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("targetTimezone", &up.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("about", &up.About).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("mrn", &up.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("biologicalSex", &up.BiologicalSex).LengthLessThanOrEqualTo(MaxProfileFieldLen) + + up.Birthday.Validate(v.WithReference("birthday")) + up.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) + if up.DiagnosisType != "" { + v.String("diagnosisType", &up.DiagnosisType).OneOf(DiabetesTypes...) + } +} + +func (up *Profile) Normalize(normalizer structure.Normalizer) { + up.FullName = strings.TrimSpace(up.FullName) + up.DiagnosisType = strings.TrimSpace(up.DiagnosisType) + up.TargetTimezone = strings.TrimSpace(up.TargetTimezone) + up.About = strings.TrimSpace(up.About) + up.MRN = strings.TrimSpace(up.MRN) + up.BiologicalSex = strings.TrimSpace(up.BiologicalSex) + + up.Birthday.Normalize(normalizer.WithReference("birthday")) + up.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) + if up.Clinic != nil { + up.Clinic.Normalize(normalizer.WithReference("clinic")) + } +} + +func (p *ClinicProfile) Normalize(normalizer structure.Normalizer) { + if p.Name != nil { + *p.Name = strings.TrimSpace(*p.Name) + } + if p.Role != nil { + *p.Role = strings.TrimSpace(*p.Role) + } + if p.Telephone != nil { + *p.Telephone = strings.TrimSpace(*p.Telephone) + } + if p.NPI != nil { + *p.NPI = strings.TrimSpace(*p.NPI) + } +} + +func (up *LegacyUserProfile) Validate(v structure.Validator) { + if up.Patient != nil { + up.Patient.Validate(v.WithReference("patient")) + } + v.String("fullName", &up.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) +} + +func (up *LegacyUserProfile) Normalize(normalizer structure.Normalizer) { + up.FullName = strings.TrimSpace(up.FullName) + if up.Patient != nil { + up.Patient.Normalize(normalizer.WithReference("patient")) + } + if up.Clinic != nil { + up.Clinic.Normalize(normalizer.WithReference("clinic")) + } + // Email and Emails are read-only so they are ignored in normalizing / validation +} + +func (pp *LegacyPatientProfile) Validate(v structure.Validator) { + pp.Birthday.Validate(v.WithReference("birthday")) + pp.DiagnosisDate.Validate(v.WithReference("diagnosisDate")) + + v.String("fullName", pp.FullName).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("targetTimezone", &pp.TargetTimezone).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("about", &pp.About).LengthLessThanOrEqualTo(MaxProfileFieldLen) + v.String("mrn", &pp.MRN).LengthLessThanOrEqualTo(MaxProfileFieldLen) + + if pp.DiagnosisType != "" { + v.String("diagnosisType", &pp.DiagnosisType).OneOf(DiabetesTypes...) + } +} + +func (pp *LegacyPatientProfile) Normalize(normalizer structure.Normalizer) { + pp.Birthday.Normalize(normalizer.WithReference("birthday")) + pp.DiagnosisDate.Normalize(normalizer.WithReference("diagnosisDate")) + + if pp.FullName != nil { + pp.FullName = pointer.FromString(strings.TrimSpace(pointer.ToString(pp.FullName))) + } + pp.DiagnosisType = strings.TrimSpace(pp.DiagnosisType) + if pp.TargetTimezone != "" { + pp.TargetTimezone = strings.TrimSpace(pp.TargetTimezone) + } + pp.About = strings.TrimSpace(pp.About) + pp.MRN = strings.TrimSpace(pp.MRN) + pp.BiologicalSex = strings.TrimSpace(pp.BiologicalSex) +} + +func (p *LegacyUserProfile) Sanitize() { + // Clear out patient fields + if p.Patient != nil { + p.Patient.Birthday = "" + p.Patient.DiagnosisDate = "" + p.Patient.DiagnosisType = "" + p.Patient.TargetDevices = nil + p.Patient.TargetTimezone = "" + p.Patient.About = "" + p.Patient.MRN = "" + p.Patient.BiologicalSex = "" + } +} diff --git a/user/profile_test.go b/user/profile_test.go new file mode 100644 index 0000000000..6f40c5ff1d --- /dev/null +++ b/user/profile_test.go @@ -0,0 +1,112 @@ +package user_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/tidepool-org/platform/pointer" + "github.com/tidepool-org/platform/user" +) + +var _ = Describe("User", func() { + Context("LegacySeagullDocument", func() { + Context("AddProfileToSeagullValue", func() { + It("Preserves non profile seagull fields such as settings, etc", func() { + seagullValueBefore := `{ + "profile": {"fullName": "something"}, + "preferences": { "clickedUploaderBannerTime": "2023-01-10T10:11:12-08:00" }, + "settings": { "bgTarget": { "high": 160, "low": 60 }, "units": { "bg": "mg/dL" } } + }` + addedProfile := &user.LegacyUserProfile{ + FullName: "Some Name", + Patient: &user.LegacyPatientProfile{ + Birthday: "2000-03-04", + DiagnosisDate: "2001-03-05", + About: "About me", + }, + MigrationStatus: user.MigrationCompleted, + } + expectedNewSeagullValue := `{ + "profile": {"fullName": "Some Name", "patient": { "birthday": "2000-03-04", "diagnosisDate": "2001-03-05", "about": "About me"}}, + "preferences": { "clickedUploaderBannerTime": "2023-01-10T10:11:12-08:00" }, + "settings": { "bgTarget": { "high": 160, "low": 60 }, "units": { "bg": "mg/dL" } }}` + + newValue, err := user.AddProfileToSeagullValue(seagullValueBefore, addedProfile) + Expect(err).ShouldNot(HaveOccurred()) + Expect(newValue).To(MatchJSON(expectedNewSeagullValue)) + }) + }) + }) + + Context("Profile", func() { + DescribeTable("ToLegacyProfile", + func(profile *user.Profile, legacyProfile *user.LegacyUserProfile, roles []string) { + Expect(profile.ToLegacyProfile(roles)).To(BeComparableTo(legacyProfile)) + }, + Entry("Regular patient", &user.Profile{ + FullName: "Bob", + Birthday: "2000-02-03", + About: "About me", + MRN: "1112222", + TargetDevices: []string{"SomeDevice900"}, + TargetTimezone: "UTC", + }, + &user.LegacyUserProfile{ + FullName: "Bob", + Patient: &user.LegacyPatientProfile{ + Birthday: "2000-02-03", + About: "About me", + MRN: "1112222", + TargetDevices: []string{"SomeDevice900"}, + TargetTimezone: "UTC", + }, + MigrationStatus: user.MigrationCompleted, + }, + []string{user.RolePatient}, + ), + Entry("Fake child", &user.Profile{ + FullName: "Child Name", + Birthday: "2000-02-03", + DiagnosisDate: "2001-02-03", + About: "About me", + Custodian: &user.Custodian{ + FullName: "Parent Name", + }, + }, + &user.LegacyUserProfile{ + FullName: "Parent Name", + Patient: &user.LegacyPatientProfile{ + FullName: pointer.FromString("Child Name"), + Birthday: "2000-02-03", + DiagnosisDate: "2001-02-03", + About: "About me", + IsOtherPerson: true, + }, + MigrationStatus: user.MigrationCompleted, + }, + []string{user.RolePatient}, + ), + Entry("Clinic", &user.Profile{ + FullName: "Clinician Name", + Clinic: &user.ClinicProfile{ + Name: pointer.FromString("Clinic Name"), + Role: pointer.FromString("Some Role"), + Telephone: pointer.FromString("123-123-3456"), + NPI: pointer.FromString("1234567890"), + }, + }, + &user.LegacyUserProfile{ + FullName: "Clinician Name", + Clinic: &user.ClinicProfile{ + Name: pointer.FromString("Clinic Name"), + Role: pointer.FromString("Some Role"), + Telephone: pointer.FromString("123-123-3456"), + NPI: pointer.FromString("1234567890"), + }, + MigrationStatus: user.MigrationCompleted, + }, + []string{user.RoleClinician}, + ), + ) + }) +}) diff --git a/user/test/user.go b/user/test/user.go index ab7abe9ba8..b877fc02ee 100644 --- a/user/test/user.go +++ b/user/test/user.go @@ -44,23 +44,23 @@ func CloneUser(datum *user.User) *user.User { return clone } -func NewObjectFromUser(datum *user.User, objectFormat test.ObjectFormat) map[string]interface{} { +func NewObjectFromUser(datum *user.User, objectFormat test.ObjectFormat) map[string]any { if datum == nil { return nil } - object := map[string]interface{}{} + object := map[string]any{} if datum.UserID != nil { object["userid"] = test.NewObjectFromString(*datum.UserID, objectFormat) } - if datum.Username != nil { + if datum.Username != nil && !user.IsUnclaimedCustodialEmail(*datum.Username) { object["username"] = test.NewObjectFromString(*datum.Username, objectFormat) } - if datum.EmailVerified != nil { - object["emailVerified"] = test.NewObjectFromBool(*datum.EmailVerified, objectFormat) - } if datum.TermsAccepted != nil { object["termsAccepted"] = test.NewObjectFromString(*datum.TermsAccepted, objectFormat) } + if datum.EmailVerified != nil { + object["emailVerified"] = test.NewObjectFromBool(*datum.EmailVerified, objectFormat) + } if datum.Roles != nil { object["roles"] = test.NewObjectFromStringArray(*datum.Roles, objectFormat) } @@ -71,13 +71,14 @@ func MatchUser(datum *user.User) gomegaTypes.GomegaMatcher { if datum == nil { return gomega.BeNil() } - return gomegaGstruct.PointTo(gomegaGstruct.MatchAllFields(gomegaGstruct.Fields{ - "UserID": gomega.Equal(datum.UserID), - "Username": gomega.Equal(datum.Username), - "EmailVerified": gomega.Equal(datum.EmailVerified), - "TermsAccepted": gomega.Equal(datum.TermsAccepted), - "Roles": gomega.Equal(datum.Roles), - })) + return gomegaGstruct.PointTo(gomegaGstruct.MatchFields(gomegaGstruct.IgnoreExtras, + gomegaGstruct.Fields{ + "UserID": gomega.Equal(datum.UserID), + "Username": gomega.Equal(datum.Username), + "EmailVerified": gomega.Equal(datum.EmailVerified), + "TermsAccepted": gomega.Equal(datum.TermsAccepted), + "Roles": gomega.Equal(datum.Roles), + })) } func RandomUsername() string { diff --git a/user/user.go b/user/user.go index 47eb8734b7..662b705a4f 100644 --- a/user/user.go +++ b/user/user.go @@ -4,11 +4,13 @@ import ( "context" "regexp" "slices" + "strings" "time" "github.com/tidepool-org/platform/pointer" "github.com/tidepool-org/platform/id" + "github.com/tidepool-org/platform/permission" "github.com/tidepool-org/platform/request" "github.com/tidepool-org/platform/structure" structureValidator "github.com/tidepool-org/platform/structure/validator" @@ -24,15 +26,20 @@ const ( RolePatient = "patient" ) -var rolesMap = map[string]any{ - RoleBrokered: struct{}{}, - RoleCarePartner: struct{}{}, - RoleClinic: struct{}{}, - RoleClinician: struct{}{}, - RoleCustodialAccount: struct{}{}, - RoleDemo: struct{}{}, - RolePatient: struct{}{}, -} +var ( + rolesMap = map[string]any{ + RoleBrokered: struct{}{}, + RoleCarePartner: struct{}{}, + RoleClinic: struct{}{}, + RoleClinician: struct{}{}, + RoleCustodialAccount: struct{}{}, + RoleDemo: struct{}{}, + RolePatient: struct{}{}, + } + + IdExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`) + custodialAccountRegexp = regexp.MustCompile(`(?i)unclaimed-custodial-automation\+\d+@tidepool\.org`) +) func Roles() []string { return []string{ @@ -52,11 +59,27 @@ type Client interface { } type User struct { - UserID *string `json:"userid,omitempty" bson:"userid,omitempty"` - Username *string `json:"username,omitempty" bson:"username,omitempty"` - EmailVerified *bool `json:"emailVerified,omitempty" bson:"emailVerified,omitempty"` - TermsAccepted *string `json:"termsAccepted,omitempty" bson:"termsAccepted,omitempty"` - Roles *[]string `json:"roles,omitempty" bson:"roles,omitempty"` + UserID *string `json:"userid,omitempty"` + Username *string `json:"username,omitempty"` + EmailVerified *bool `json:"emailVerified,omitempty"` + TermsAccepted *string `json:"termsAccepted,omitempty"` + Roles *[]string `json:"roles,omitempty"` + Enabled bool `json:"-"` + Profile *Profile `json:"profile,omitempty"` + Attributes map[string][]string `json:"-"` +} + +// TrustUser is the user object returned for the /v1/users/:userId/users route. +type TrustUser struct { + User + TrustPermissions +} + +type TrustUserArray []*TrustUser + +type TrustPermissions struct { + TrusteePermissions *permission.Permission `json:"trusteePermissions,omitempty"` + TrustorPermissions *permission.Permission `json:"trustorPermissions,omitempty"` } func (u *User) Parse(parser structure.ObjectParser) { @@ -84,11 +107,7 @@ func (u *User) Validate(validator structure.Validator) { func (u *User) HasRole(role string) bool { if u.Roles != nil { - for _, r := range *u.Roles { - if r == role { - return true - } - } + return slices.Contains(*u.Roles, role) } return false } @@ -100,8 +119,16 @@ func (u *User) IsPatient() bool { return false } +func IsUnclaimedCustodialEmail(email string) bool { + return custodialAccountRegexp.MatchString(email) +} + func (u *User) Sanitize(details request.AuthDetails) error { - if details == nil || (!details.IsService() && details.UserID() != *u.UserID) { + if (details == nil || !details.IsService()) && u.Username != nil && IsUnclaimedCustodialEmail(*u.Username) { + u.Username = nil + } + + if details == nil || (!details.IsService() && (u.UserID == nil || details.UserID() != *u.UserID)) { u.Username = nil u.EmailVerified = nil u.TermsAccepted = nil @@ -110,6 +137,37 @@ func (u *User) Sanitize(details request.AuthDetails) error { return nil } +func (u *User) Email() string { + if u.Username != nil { + return strings.ToLower(*u.Username) + } + return "" +} + +func (u *TrustUser) Sanitize(details request.AuthDetails) error { + if (details == nil || !details.IsService()) && u.Username != nil && IsUnclaimedCustodialEmail(*u.Username) { + u.Username = nil + } + if details == nil || (!details.IsService() && details.UserID() != *u.UserID) { + // Note that a TrustUser includes some fields in the user that + // [User.Sanitize] wouldn't so it is not called directly as it may clear + // out those fields. + if (u.TrustorPermissions == nil || len(*u.TrustorPermissions) == 0) && u.User.Profile != nil { + u.User.Profile.Sanitize() + } + } + return nil +} + +func (us TrustUserArray) Sanitize(details request.AuthDetails) error { + for _, u := range us { + if err := u.Sanitize(details); err != nil { + return err + } + } + return nil +} + type UserArray []*User func (u UserArray) Sanitize(details request.AuthDetails) error { @@ -136,10 +194,13 @@ func IDValidator(value string, errorReporter structure.ErrorReporter) { func ValidateID(value string) error { if value == "" { return structureValidator.ErrorValueEmpty() - } else if !idExpression.MatchString(value) { + } else if !IdExpression.MatchString(value) { return ErrorValueStringAsIDNotValid(value) } return nil } -var idExpression = regexp.MustCompile(`^([0-9a-f]{10}|[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})$`) +// IsValidUserID return true if the string is in a human readable uuid hex 8-4-4-4-12 format or legacy alphanumeric 10 characters +func IsValidUserID(id string) bool { + return IdExpression.MatchString(id) +} diff --git a/user/user_accessor.go b/user/user_accessor.go new file mode 100644 index 0000000000..2d137dc6bc --- /dev/null +++ b/user/user_accessor.go @@ -0,0 +1,48 @@ +package user + +import ( + "context" + "errors" +) + +//go:generate mockgen -build_flags=--mod=mod -destination=./user_mock.go -package=user . ProfileAccessor,UserAccessor + +var ( + ShorelineManagedRoles = map[string]struct{}{"patient": {}, "clinic": {}, "clinician": {}, "custodial_account": {}} + + ErrUserNotFound = errors.New("user not found") + ErrUserProfileNotFound = errors.New("profile not found") + ErrUserNotMigrated = errors.New("user has not been migrated") + ErrProfileNotMigrated = errors.New("profile has not been migrated") + + // ErrUserProfileMigrationInProgress means a specific user profile is + // currently being migrated so the client should ideally wait and + // retry their operation again since the migration for a single user + // should be no longer than a few seconds. + ErrUserProfileMigrationInProgress = errors.New("user migration is in progress") +) + +type LegacyProfileAccessor interface { + FindLegacyUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) + UpdateLegacyUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) error +} + +type ProfileAccessor interface { + LegacyProfileAccessor + UpdateUserProfile(ctx context.Context, userID string, p *Profile) error +} + +type RoleGetter interface { + Roles(ctx context.Context, userID string) ([]string, error) +} + +// UserAccessor is the interface that can retrieve users. +// It is the equivalent of shoreline's shoreline's Storage +// interface, but for now will only retrieve user +// information. +type UserAccessor interface { + ProfileAccessor + RoleGetter + Get(ctx context.Context, id string) (*User, error) + FindUsersWithIds(ctx context.Context, ids []string) ([]*User, error) +} diff --git a/user/user_mock.go b/user/user_mock.go new file mode 100644 index 0000000000..c0a088bd7a --- /dev/null +++ b/user/user_mock.go @@ -0,0 +1,196 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/user (interfaces: ProfileAccessor,UserAccessor) +// +// Generated by this command: +// +// mockgen -build_flags=--mod=mod -destination=./user_mock.go -package=user . ProfileAccessor,UserAccessor +// + +// Package user is a generated GoMock package. +package user + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockProfileAccessor is a mock of ProfileAccessor interface. +type MockProfileAccessor struct { + ctrl *gomock.Controller + recorder *MockProfileAccessorMockRecorder + isgomock struct{} +} + +// MockProfileAccessorMockRecorder is the mock recorder for MockProfileAccessor. +type MockProfileAccessorMockRecorder struct { + mock *MockProfileAccessor +} + +// NewMockProfileAccessor creates a new mock instance. +func NewMockProfileAccessor(ctrl *gomock.Controller) *MockProfileAccessor { + mock := &MockProfileAccessor{ctrl: ctrl} + mock.recorder = &MockProfileAccessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProfileAccessor) EXPECT() *MockProfileAccessorMockRecorder { + return m.recorder +} + +// FindLegacyUserProfile mocks base method. +func (m *MockProfileAccessor) FindLegacyUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLegacyUserProfile", ctx, userID) + ret0, _ := ret[0].(*LegacyUserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLegacyUserProfile indicates an expected call of FindLegacyUserProfile. +func (mr *MockProfileAccessorMockRecorder) FindLegacyUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLegacyUserProfile", reflect.TypeOf((*MockProfileAccessor)(nil).FindLegacyUserProfile), ctx, userID) +} + +// UpdateLegacyUserProfile mocks base method. +func (m *MockProfileAccessor) UpdateLegacyUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLegacyUserProfile", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateLegacyUserProfile indicates an expected call of UpdateLegacyUserProfile. +func (mr *MockProfileAccessorMockRecorder) UpdateLegacyUserProfile(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLegacyUserProfile", reflect.TypeOf((*MockProfileAccessor)(nil).UpdateLegacyUserProfile), ctx, userID, p) +} + +// UpdateUserProfile mocks base method. +func (m *MockProfileAccessor) UpdateUserProfile(ctx context.Context, userID string, p *Profile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfile", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfile indicates an expected call of UpdateUserProfile. +func (mr *MockProfileAccessorMockRecorder) UpdateUserProfile(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockProfileAccessor)(nil).UpdateUserProfile), ctx, userID, p) +} + +// MockUserAccessor is a mock of UserAccessor interface. +type MockUserAccessor struct { + ctrl *gomock.Controller + recorder *MockUserAccessorMockRecorder + isgomock struct{} +} + +// MockUserAccessorMockRecorder is the mock recorder for MockUserAccessor. +type MockUserAccessorMockRecorder struct { + mock *MockUserAccessor +} + +// NewMockUserAccessor creates a new mock instance. +func NewMockUserAccessor(ctrl *gomock.Controller) *MockUserAccessor { + mock := &MockUserAccessor{ctrl: ctrl} + mock.recorder = &MockUserAccessorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserAccessor) EXPECT() *MockUserAccessorMockRecorder { + return m.recorder +} + +// FindLegacyUserProfile mocks base method. +func (m *MockUserAccessor) FindLegacyUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindLegacyUserProfile", ctx, userID) + ret0, _ := ret[0].(*LegacyUserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindLegacyUserProfile indicates an expected call of FindLegacyUserProfile. +func (mr *MockUserAccessorMockRecorder) FindLegacyUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindLegacyUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).FindLegacyUserProfile), ctx, userID) +} + +// FindUsersWithIds mocks base method. +func (m *MockUserAccessor) FindUsersWithIds(ctx context.Context, ids []string) ([]*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUsersWithIds", ctx, ids) + ret0, _ := ret[0].([]*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUsersWithIds indicates an expected call of FindUsersWithIds. +func (mr *MockUserAccessorMockRecorder) FindUsersWithIds(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUsersWithIds", reflect.TypeOf((*MockUserAccessor)(nil).FindUsersWithIds), ctx, ids) +} + +// Get mocks base method. +func (m *MockUserAccessor) Get(ctx context.Context, id string) (*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, id) + ret0, _ := ret[0].(*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockUserAccessorMockRecorder) Get(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockUserAccessor)(nil).Get), ctx, id) +} + +// Roles mocks base method. +func (m *MockUserAccessor) Roles(ctx context.Context, userID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Roles", ctx, userID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Roles indicates an expected call of Roles. +func (mr *MockUserAccessorMockRecorder) Roles(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Roles", reflect.TypeOf((*MockUserAccessor)(nil).Roles), ctx, userID) +} + +// UpdateLegacyUserProfile mocks base method. +func (m *MockUserAccessor) UpdateLegacyUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateLegacyUserProfile", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateLegacyUserProfile indicates an expected call of UpdateLegacyUserProfile. +func (mr *MockUserAccessorMockRecorder) UpdateLegacyUserProfile(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLegacyUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).UpdateLegacyUserProfile), ctx, userID, p) +} + +// UpdateUserProfile mocks base method. +func (m *MockUserAccessor) UpdateUserProfile(ctx context.Context, userID string, p *Profile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfile", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfile indicates an expected call of UpdateUserProfile. +func (mr *MockUserAccessorMockRecorder) UpdateUserProfile(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).UpdateUserProfile), ctx, userID, p) +} diff --git a/user/user_test.go b/user/user_test.go index ec067fb752..3e6bc7304c 100644 --- a/user/user_test.go +++ b/user/user_test.go @@ -42,7 +42,6 @@ var _ = Describe("User", func() { datum := userTest.RandomUser() mutator(datum) test.ExpectSerializedObjectJSON(datum, userTest.NewObjectFromUser(datum, test.ObjectFormatJSON)) - test.ExpectSerializedObjectBSON(datum, userTest.NewObjectFromUser(datum, test.ObjectFormatBSON)) }, Entry("succeeds", func(datum *user.User) {},