diff --git a/auth/service/api/v1/appvalidate_test.go b/auth/service/api/v1/appvalidate_test.go deleted file mode 100644 index 6dee6b5033..0000000000 --- a/auth/service/api/v1/appvalidate_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package v1_test - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "time" - - "github.com/ant0ine/go-json-rest/rest" - "go.uber.org/mock/gomock" - - "github.com/tidepool-org/platform/appvalidate" - appvalidateTest "github.com/tidepool-org/platform/appvalidate/test" - "github.com/tidepool-org/platform/auth" - v1 "github.com/tidepool-org/platform/auth/service/api/v1" - authServiceTest "github.com/tidepool-org/platform/auth/service/test" - authTest "github.com/tidepool-org/platform/auth/test" - "github.com/tidepool-org/platform/errors" - "github.com/tidepool-org/platform/log" - logTest "github.com/tidepool-org/platform/log/test" - "github.com/tidepool-org/platform/pointer" - "github.com/tidepool-org/platform/request" - "github.com/tidepool-org/platform/service/middleware" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("App Validation", func() { - defer GinkgoRecover() - - var ctrl *gomock.Controller - var service *authServiceTest.Service - var repo *appvalidateTest.MockRepository - var generator *appvalidateTest.MockChallengeGenerator - var authClient *authTest.MockClient - var handler http.Handler - - challenge := "challenge" - serverSessionToken := "serverToken" - - unattestedUser := user{ - UserID: "unattested", - SessionToken: "unattestedToken", - Details: request.NewAuthDetails(request.MethodSessionToken, "unattested", "unattestedToken"), - AttestationVerified: false, - } - attestedUser := user{ - UserID: "attested", - SessionToken: "attestedToken", - Details: request.NewAuthDetails(request.MethodSessionToken, "attested", "attestedToken"), - KeyID: "YWJjZGVmYWJjZGVm", - AttestationVerified: false, - AttestationChallenge: challenge, - } - attestedUnverifiedUser := user{ - UserID: "attestedUnverified", - SessionToken: "attestedUnverifiedToken", - Details: request.NewAuthDetails(request.MethodSessionToken, "attestedUnverified", "attestedUnverified"), - KeyID: "YWRzZmFkZg==", - AttestationVerified: false, - AttestationChallenge: challenge, - } - attestedVerifiedUser := user{ - UserID: "attestedVerified", - SessionToken: "attestedVerifiedToken", - Details: request.NewAuthDetails(request.MethodSessionToken, "attestedVerified", "attestedVerifiedToken"), - KeyID: "YWJkZmRlZg=", - AttestationVerified: true, - AttestationChallenge: challenge, - AssertionChallenge: challenge, - } - users := []user{ - unattestedUser, - attestedUser, - attestedVerifiedUser, - attestedUnverifiedUser, - } - - initialValidations := make([]appvalidate.AppValidation, len(users)) - for i, user := range users { - validation := appvalidate.AppValidation{ - UserID: user.UserID, - KeyID: user.KeyID, - Verified: user.AttestationVerified, - AttestationChallenge: user.AttestationChallenge, - AssertionChallenge: user.AssertionChallenge, - } - if user.AttestationVerified { - validation.AttestationVerifiedTime = pointer.FromTime(time.Date(2023, time.January, 3, 10, 0, 0, 0, time.UTC)) - } - initialValidations[i] = validation - } - BeforeEach(func() { - ctrl = gomock.NewController(GinkgoT()) - service = authServiceTest.NewService() - repo = newRepository(ctrl, initialValidations) - generator = appvalidateTest.NewMockChallengeGenerator(ctrl) - authClient = authTest.NewMockClient(ctrl) - - service.LoggerImpl = logTest.NewLogger() - generator.EXPECT(). - GenerateChallenge(gomock.Any()). - Return(challenge, nil). - AnyTimes() - validator, err := appvalidate.NewValidator(repo, generator, appvalidate.ValidatorConfig{ - AppleAppIDs: []string{"org.tidepool.app"}, - ChallengeSize: 10, - }) - Expect(err).ToNot(HaveOccurred()) - - service.AppvalidateValidatorImpl = validator - - authClient.EXPECT(). - ServerSessionToken(). - Return(serverSessionToken, nil). - AnyTimes() - authClient.EXPECT(). - ValidateSessionToken(gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, token string) (request.AuthDetails, error) { - for _, user := range users { - if token == user.SessionToken { - return user.Details, nil - } - } - return nil, request.ErrorUnauthorized() - }). - AnyTimes() - - api := rest.NewApi() - - router, err := v1.NewRouter(service) - Expect(err).ToNot(HaveOccurred()) - - authMiddleware, err := middleware.NewAuthenticator("secret", authClient) - Expect(err).ToNot(HaveOccurred()) - - // Use a subset of the middlewares used in the actual - // API.InitializeMiddleware - just auth is really needed for testing. - middlewares := []rest.Middleware{ - authMiddleware, - } - api.Use(middlewares...) - - app, err := rest.MakeRouter(router.Routes()...) - if err != nil { - Expect(err).ToNot(HaveOccurred()) - } - api.SetApp(app) - handler = api.MakeHandler() - }) - - Describe("POST /v1/attestations/challenges", func() { - It("succeeds with correct input", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "YWJjZGVmZ2hpamFiY2RlZmdoaWphYmNkZWZnaGlq", - } - req := newRequest(http.MethodPost, "/v1/attestations/challenges", unattestedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusCreated)) - resp := w.Result() - var result appvalidate.ChallengeResult - err := unmashalBody(resp.Body, &result) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Challenge).To(Equal(challenge)) - }) - - It("fails with empty keyID", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "", - } - - req := newRequest(http.MethodPost, "/v1/attestations/challenges", unattestedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("fails if unauthorized", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "YWJjZGVmZ2hpamFiY2RlZmdoaWphYmNkZWZnaGlq", - } - - noSessionToken := "" - req := newRequest(http.MethodPost, "/v1/attestations/challenges", noSessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("fails with bad session token", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "YWJjZGVmZ2hpamFiY2RlZmdoaWphYmNkZWZnaGlq", - } - - badSessionToken := "BAD_TOKEN!" - req := newRequest(http.MethodPost, "/v1/attestations/challenges", badSessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("POST /v1/assertions/challenges", func() { - It("fails with an unverified user", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: attestedUnverifiedUser.KeyID, - } - - req := newRequest(http.MethodPost, "/v1/assertions/challenges", attestedUnverifiedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("succeeds only with a verified attested user", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: attestedVerifiedUser.KeyID, - } - - req := newRequest(http.MethodPost, "/v1/assertions/challenges", attestedVerifiedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusCreated)) - resp := w.Result() - var result appvalidate.ChallengeResult - err := unmashalBody(resp.Body, &result) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Challenge).To(Equal(challenge)) - }) - - It("fails with empty keyID", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "", - } - - req := newRequest(http.MethodPost, "/v1/assertions/challenges", unattestedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("fails if unauthorized", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "YWJjZGVmZ2hpamFiY2RlZmdoaWphYmNkZWZnaGlq", - } - - noSessionToken := "" - req := newRequest(http.MethodPost, "/v1/assertions/challenges", noSessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("fails with bad session token", func() { - body := &appvalidate.ChallengeCreate{ - KeyID: "YWJjZGVmZ2hpamFiY2RlZmdoaWphYmNkZWZnaGlq", - } - - badSessionToken := "BAD_TOKEN!" - req := newRequest(http.MethodPost, "/v1/assertions/challenges", badSessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("POST /v1/attestations/verifications", func() { - // Was going to use an actual signed object from apple - // but unfortunately the expiration time for that is only - // a few days so there is no integration test for that. - It("fails on attestation that is not base64 encoded", func() { - body := &appvalidate.AttestationVerify{ - KeyID: attestedUser.KeyID, - Challenge: challenge, - Attestation: `{"key": "field"}`, - } - - req := newRequest(http.MethodPost, "/v1/attestations/verifications", attestedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - It("fails on incorrect attestation", func() { - body := &appvalidate.AttestationVerify{ - KeyID: attestedUser.KeyID, - Challenge: challenge, - Attestation: `YWJjZGVm`, - } - - req := newRequest(http.MethodPost, "/v1/attestations/verifications", attestedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("POST /v1/assertions/verifications", func() { - It("fails on assertion that is not base64 encoded", func() { - body := &appvalidate.AssertionVerify{ - KeyID: attestedVerifiedUser.KeyID, - ClientData: appvalidate.AssertionClientData{ - Challenge: challenge, - }, - Assertion: `{"key": "field"}`, - } - - req := newRequest(http.MethodPost, "/v1/assertions/verifications", attestedVerifiedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - It("fails on incorrect assertion", func() { - body := &appvalidate.AssertionVerify{ - KeyID: attestedVerifiedUser.KeyID, - ClientData: appvalidate.AssertionClientData{ - Challenge: challenge, - }, - Assertion: `YWJjZGVm`, - } - - req := newRequest(http.MethodPost, "/v1/assertions/verifications", attestedVerifiedUser.SessionToken, body) - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - }) -}) - -// user is a helper user that contains relevant user information for tests. -type user struct { - UserID string - SessionToken string - Details request.AuthDetails - KeyID string - AttestationVerified bool - AttestationChallenge string - AssertionChallenge string -} - -// newRequest wraps httptest.NewRequest w/ a default logger as some of the -// middleware expect the logger to be present so this prevents a nil pointer -// dereference. body can be nil, an io.Reader, or a struct that is assumed to -// be JSON marshalable -func newRequest(method, url, sessionToken string, body interface{}) *http.Request { - var newBody io.Reader - var contentType string - - if body != nil { - switch v := body.(type) { - case string: - newBody = strings.NewReader(v) - case []byte: - newBody = bytes.NewReader(v) - case io.Reader: - newBody = v - default: - body, err := json.Marshal(v) - if err == nil { - newBody = bytes.NewReader(body) - contentType = "application/json" - } - } - } - req := httptest.NewRequest(method, url, newBody) - if contentType != "" { - req.Header.Add("content-type", contentType) - } - if sessionToken != "" { - req.Header.Add(auth.TidepoolSessionTokenHeaderKey, sessionToken) - } - ctx := log.NewContextWithLogger(req.Context(), logTest.NewLogger()) - return req.Clone(ctx) -} - -func unmashalBody(r io.ReadCloser, result interface{}) error { - defer r.Close() - return json.NewDecoder(r).Decode(result) -} - -func newRepository(ctrl *gomock.Controller, initialValidations []appvalidate.AppValidation) *appvalidateTest.MockRepository { - // In memory map for persistence across calls. - // [appvalidate.Filter] => *appvalidate.AppValidation - mapping := &sync.Map{} - - for _, appValidation := range initialValidations { - // Make a copy since storing &appValidation is shared in the range loop. - copy := appValidation - mapping.Store(appvalidate.Filter{UserID: copy.UserID, KeyID: copy.KeyID}, ©) - } - - repo := appvalidateTest.NewMockRepository(ctrl) - repo.EXPECT(). - Upsert(gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, v *appvalidate.AppValidation) error { - mapping.Store(appvalidate.Filter{UserID: v.UserID, KeyID: v.KeyID}, v) - return nil - }). - AnyTimes() - - repo.EXPECT(). - IsVerified(gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, f appvalidate.Filter) (bool, error) { - verificationRaw, ok := mapping.Load(f) - if !ok { - return false, errors.New("not found") - } - return verificationRaw.(*appvalidate.AppValidation).Verified, nil - }). - AnyTimes() - - repo.EXPECT(). - GetAttestationChallenge(gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, f appvalidate.Filter) (string, error) { - verificationRaw, ok := mapping.Load(f) - if !ok { - return "", errors.New("not found") - } - return verificationRaw.(*appvalidate.AppValidation).AttestationChallenge, nil - }). - AnyTimes() - - repo.EXPECT(). - Get(gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, f appvalidate.Filter) (*appvalidate.AppValidation, error) { - verificationRaw, ok := mapping.Load(f) - if !ok { - return nil, errors.New("not found") - } - return verificationRaw.(*appvalidate.AppValidation), nil - }). - AnyTimes() - - repo.EXPECT(). - UpdateAssertion(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, f appvalidate.Filter, u appvalidate.AssertionUpdate) error { - verificationRaw, ok := mapping.Load(f) - if !ok { - return errors.New("not found") - } - verification := verificationRaw.(*appvalidate.AppValidation) - // Ignore zero values like the `bson:",omitempty"` tag does - if !u.VerifiedTime.IsZero() { - verification.AssertionVerifiedTime = &u.VerifiedTime - } - if u.AssertionCounter > 0 { - verification.AssertionCounter = u.AssertionCounter - } - if u.Challenge != "" { - verification.AssertionChallenge = u.Challenge - } - return nil - }). - AnyTimes() - - repo.EXPECT(). - UpdateAttestation(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, f appvalidate.Filter, u appvalidate.AttestationUpdate) error { - verificationRaw, ok := mapping.Load(f) - if !ok { - return errors.New("not found") - } - verification := verificationRaw.(*appvalidate.AppValidation) - verification.PublicKey = u.PublicKey - verification.Verified = u.Verified - verification.FraudAssessmentReceipt = u.FraudAssessmentReceipt - verification.AttestationVerifiedTime = &u.VerifiedTime - return nil - }). - AnyTimes() - - return repo -} diff --git a/auth/service/api/v1/permission.go b/auth/service/api/v1/permission.go new file mode 100644 index 0000000000..d02661ee6a --- /dev/null +++ b/auth/service/api/v1/permission.go @@ -0,0 +1,116 @@ +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().HasMembershipRelationship(ctx, details.UserID(), targetUserID) + if err != nil { + responder.InternalServerError(err) + return + } + if !hasPerms { + responder.Empty(http.StatusForbidden) + return + } + handlerFunc(res, req) + } + } +} + +// requireWriteAccess aborts with an error if the request isn't a server request +// or the authenticated user doesn't have access to the user id in the url param, +// targetParamUserID +func (r *Router) requireWriteAccess(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 { + responder.Empty(http.StatusUnauthorized) + return + } + if details.IsService() { + handlerFunc(res, req) + return + } + hasPerms, err := r.PermissionsClient().HasWritePermissions(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..2a6a82c02f --- /dev/null +++ b/auth/service/api/v1/profile.go @@ -0,0 +1,245 @@ +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/log" + "github.com/tidepool-org/platform/permission" + "github.com/tidepool-org/platform/request" + structValidator "github.com/tidepool-org/platform/structure/validator" + "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("/v1/users/:userId/users", r.requireMembership("userId", r.GetUsersWithProfiles)), + rest.Get("/v1/users/legacy/:userId/profile", r.requireMembership("userId", r.GetLegacyProfile)), + rest.Put("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + rest.Put("/v1/users/legacy/:userId/profile", r.requireCustodian("userId", r.UpdateLegacyProfile)), + rest.Post("/v1/users/:userId/profile", r.requireCustodian("userId", r.UpdateProfile)), + rest.Post("/v1/users/legacy/: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().FindUserProfile(ctx, userID) + if err != nil { + return nil, err + } + if profile == nil { + return nil, user.ErrUserProfileNotFound + } + 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.getProfile(ctx, userID) + if err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + + responder.Data(http.StatusOK, profile) +} + +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().FindUserById(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().GroupsForUser(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().UsersInGroup(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 := make(user.UserArray, 0, len(mergedUserPerms)) + group, ctx := errgroup.WithContext(ctx) + group.SetLimit(20) // do up to 20 concurrent requests like seagull did + for userID, trustPerms := range mergedUserPerms { + userID, trustPerms := userID, trustPerms + group.Go(func() error { + sharedUser, err := r.UserAccessor().FindUserById(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 + } + trustorPerms := trustPerms.TrustorPermissions + + profile := seagullProfile.ToUserProfile() + if trustorPerms == nil || len(*trustorPerms) == 0 { + profile = profile.ClearPatientInfo() + } else { + if trustorPerms.HasAny(permission.Custodian, permission.Read, permission.Write) { + // TODO: need to read seagull.value.settings - confirm this is actually used + } + if trustorPerms.Has(permission.Custodian) { + // TODO: need to read seagull.value.preferences - confirm this is actually used + } + } + sharedUser.Profile = profile + sharedUser.TrusteePermissions = trustPerms.TrusteePermissions + sharedUser.TrustorPermissions = trustPerms.TrustorPermissions + // type UsersArray implements Sanitize to hide any properties for non service requests + lock.Lock() + results = append(results, sharedUser) + lock.Unlock() + return nil + }) + } + if err := group.Wait(); err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + + 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.getProfile(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 := structValidator.New(log.LoggerFromContext(ctx)).Validate(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) UpdateProfile(res rest.ResponseWriter, req *rest.Request) { + responder := request.MustNewResponder(res, req) + ctx := req.Context() + userID := req.PathParam("userId") + + profile := &user.UserProfile{} + if err := request.DecodeRequestBody(req.Request, profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if err := structValidator.New(log.LoggerFromContext(ctx)).Validate(profile); err != nil { + responder.Error(http.StatusBadRequest, err) + return + } + if err := r.ProfileAccessor().UpdateUserProfileV2(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) + ctx := req.Context() + userID := req.PathParam("userId") + + err := r.ProfileAccessor().DeleteUserProfile(ctx, userID) + if err != nil { + r.handleUserOrProfileErr(responder, err) + return + } + responder.Empty(http.StatusOK) +} + +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..b4b4b9bc49 100644 --- a/auth/service/api/v1/router.go +++ b/auth/service/api/v1/router.go @@ -27,6 +27,7 @@ func (r *Router) Routes() []*rest.Route { r.ProviderSessionsRoutes(), r.RestrictedTokensRoutes(), r.DeviceCheckRoutes(), + r.ProfileRoutes(), r.DeviceTokensRoutes(), r.AppValidateRoutes(), } diff --git a/auth/service/api/v1/router_test.go b/auth/service/api/v1/router_test.go index 0d7b528ae5..48c4fc2999 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.MockExtendedClient BeforeEach(func() { - svc = serviceTest.NewService() + ctrl = gomock.NewController(GinkgoT()) + svc, userAccessor, profileAccessor, permsClient = serviceTest.NewMockedService(ctrl) }) Context("NewRouter", func() { @@ -45,6 +70,226 @@ 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.LegacyUserProfile + 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.LegacyUserProfile{ + Patient: &user.LegacyPatientProfile{ + FullName: pointer.FromString("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"), + } + + profileAccessor.EXPECT(). + FindUserProfile(gomock.Any(), userID). + Return(userProfile, nil).AnyTimes() + + userAccessor.EXPECT(). + FindUserById(gomock.Any(), userID). + Return(userDetails, nil).AnyTimes() + }) + + 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(). + HasMembershipRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasWritePermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + }) + + It("it succeeds if the profile exists", func() { + 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() { + 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.LegacyUserProfile + var otherDetails *user.User + BeforeEach(func() { + otherPersonID = userTest.RandomUserID() + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", otherPersonID) + otherProfile = &user.LegacyUserProfile{ + Patient: &user.LegacyPatientProfile{ + + FullName: pointer.FromString("Someone Else's Profile"), + Birthday: "2002-03-04", + DiagnosisDate: "2003-04-05", + About: "Not about me", + MRN: "11223346", + }, + } + otherDetails = &user.User{ + UserID: pointer.FromString(otherPersonID), + Username: pointer.FromString("dev+other@tidepool.org"), + } + }) + It("retrieves another person's profile if user has access", func() { + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), userID, otherPersonID). + Return(true, nil).AnyTimes() + profileAccessor.EXPECT(). + FindUserProfile(gomock.Any(), otherPersonID). + Return(otherProfile, nil).AnyTimes() + userAccessor.EXPECT(). + FindUserById(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])) + }) + It("fails to retrieve another person's profile if user does not have access", func() { + permsClient.EXPECT(). + HasMembershipRelationship(gomock.Any(), userID, otherPersonID). + Return(false, nil).AnyTimes() + handlerFunc(res, req) + Expect(res.WriteHeaderInputs).To(Equal([]int{http.StatusForbidden})) + res.WriteOutputs = nil + }) + }) + }) + }) + + Context("UpdateProfile", func() { + var updatedProfile *user.UserProfile + BeforeEach(func() { + req.Method = http.MethodPost + req.URL.Path = fmt.Sprintf("/v1/users/%s/profile", userID) + + updatedProfile = &user.UserProfile{ + 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(). + HasMembershipRelationship(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasCustodianPermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + permsClient.EXPECT(). + HasWritePermissions(gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil).AnyTimes() + + profileAccessor.EXPECT(). + UpdateUserProfileV2(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(). + UpdateUserProfileV2(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 + }) + }) + }) + }) }) }) }) diff --git a/auth/service/service.go b/auth/service/service.go index 5a9c7d959d..82ea440b8e 100644 --- a/auth/service/service.go +++ b/auth/service/service.go @@ -8,17 +8,22 @@ import ( "github.com/tidepool-org/platform/apple" "github.com/tidepool-org/platform/appvalidate" "github.com/tidepool-org/platform/auth" - authStore "github.com/tidepool-org/platform/auth/store" + "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 { service.Service Domain() string - AuthStore() authStore.Store + AuthStore() store.Store + UserAccessor() user.UserAccessor + ProfileAccessor() user.ProfileAccessor + PermissionsClient() permission.ExtendedClient ProviderFactory() provider.Factory diff --git a/auth/service/service/service.go b/auth/service/service/service.go index d6da38bc07..b089610f50 100644 --- a/auth/service/service/service.go +++ b/auth/service/service/service.go @@ -31,6 +31,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" @@ -40,6 +42,8 @@ import ( taskClient "github.com/tidepool-org/platform/task/client" "github.com/tidepool-org/platform/twiist" twiistProvider "github.com/tidepool-org/platform/twiist/provider" + "github.com/tidepool-org/platform/user" + "github.com/tidepool-org/platform/user/keycloak" "github.com/tidepool-org/platform/work" workService "github.com/tidepool-org/platform/work/service" workStoreStructuredMongo "github.com/tidepool-org/platform/work/store/structured/mongo" @@ -69,6 +73,9 @@ type Service struct { deviceCheck apple.DeviceCheck appValidator *appvalidate.Validator partnerSecrets *appvalidate.PartnerSecrets + userAccessor user.UserAccessor + userProfileAccessor user.ProfileAccessor + permsClient *permissionClient.Client twiistServiceAccountAuthorizer auth.ServiceAccountAuthorizer } @@ -134,6 +141,15 @@ func (s *Service) Initialize(provider application.Provider) error { if err := s.initializeDeviceCheck(); 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 + } if err := s.initializeAppValidate(); err != nil { return err } @@ -200,6 +216,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.ExtendedClient { + return s.permsClient +} func (s *Service) AppValidator() *appvalidate.Validator { return s.appValidator } @@ -462,6 +489,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") @@ -596,6 +642,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 e98473d5bc..ee944d89db 100644 --- a/auth/service/service/service_test.go +++ b/auth/service/service/service_test.go @@ -32,12 +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 authServiceConfig map[string]interface{} + var authClientConfig map[string]any + var authStoreConfig map[string]any + var dataClientConfig map[string]any + var dataSourceClientConfig map[string]any + var taskClientConfig map[string]any + var permissionClientConfig map[string]any + var authServiceConfig map[string]any var service *authServiceService.Service var oldKafkaConfig map[string]string @@ -56,48 +57,55 @@ 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(), } - 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, }, } diff --git a/auth/service/test/service.go b/auth/service/test/service.go index c8b16fdb67..268dd9101b 100644 --- a/auth/service/test/service.go +++ b/auth/service/test/service.go @@ -3,12 +3,18 @@ package test import ( "context" + gomock "go.uber.org/mock/gomock" + "github.com/onsi/gomega" confirmationClient "github.com/tidepool-org/hydrophone/client" "github.com/tidepool-org/platform/apple" "github.com/tidepool-org/platform/appvalidate" + + "github.com/tidepool-org/platform/permission" + "github.com/tidepool-org/platform/user" + "github.com/tidepool-org/platform/auth" authService "github.com/tidepool-org/platform/auth/service" authStore "github.com/tidepool-org/platform/auth/store" @@ -44,6 +50,9 @@ type Service struct { PartnerSecretsImpl *appvalidate.PartnerSecrets TwiistServiceAccountAuthorizerInvocations int TwiistServiceAccountAuthorizerImpl auth.ServiceAccountAuthorizer + userAccessor user.UserAccessor + permsClient permission.ExtendedClient + profileAccessor user.ProfileAccessor } func NewService() *Service { @@ -55,6 +64,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.MockExtendedClient) { + userAccessor = user.NewMockUserAccessor(ctrl) + profileAccessor = user.NewMockProfileAccessor(ctrl) + permsClient = permission.NewMockExtendedClient(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++ @@ -101,6 +128,10 @@ func (s *Service) DeviceCheck() apple.DeviceCheck { return s.DeviceCheckImpl } +func (s *Service) PermissionsClient() permission.ExtendedClient { + return s.permsClient +} + func (s *Service) Status(ctx context.Context) *authService.Status { s.StatusInvocations++ @@ -136,3 +167,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..075f4a1ffc --- /dev/null +++ b/auth/store/mongo/legacy_seagull_profile_repository.go @@ -0,0 +1,129 @@ +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) FindUserProfile(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) UpdateUserProfile(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 +} + +func (p *LegacySeagullProfileRepository) DeleteUserProfile(ctx context.Context, userID string) error { + if ctx == nil { + return errors.New("context is missing") + } + if userID == "" { + return errors.New("user id is missing") + } + + _, err := p.DeleteOne(ctx, bson.M{"userId": userID}) + if err != nil { + return errors.Wrap(err, "unable to delete user profile") + } + return nil +} diff --git a/auth/test/auth_mocks.go b/auth/test/auth_mocks.go index 8d627f96e2..f617301bfe 100644 --- a/auth/test/auth_mocks.go +++ b/auth/test/auth_mocks.go @@ -13,12 +13,11 @@ import ( context "context" reflect "reflect" - gomock "go.uber.org/mock/gomock" - auth "github.com/tidepool-org/platform/auth" page "github.com/tidepool-org/platform/page" permission "github.com/tidepool-org/platform/permission" request "github.com/tidepool-org/platform/request" + gomock "go.uber.org/mock/gomock" ) // MockClient is a mock of Client interface. diff --git a/data/client/test/mock.go b/data/client/test/mock.go new file mode 100644 index 0000000000..d56c092dc4 --- /dev/null +++ b/data/client/test/mock.go @@ -0,0 +1,266 @@ +// 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" + + data "github.com/tidepool-org/platform/data" + page "github.com/tidepool-org/platform/page" + types "github.com/tidepool-org/platform/summary/types" + 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 +} + +// 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..52526fd539 --- /dev/null +++ b/data/service/api/v1/mocks/mocklogger_test_gen.go @@ -0,0 +1,267 @@ +// 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" + + log "github.com/tidepool-org/platform/log" + gomock "go.uber.org/mock/gomock" +) + +// 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/source/test/source_mocks.go b/data/source/test/source_mocks.go index 28a154a5fe..e79426b171 100644 --- a/data/source/test/source_mocks.go +++ b/data/source/test/source_mocks.go @@ -13,11 +13,10 @@ import ( context "context" reflect "reflect" - gomock "go.uber.org/mock/gomock" - source "github.com/tidepool-org/platform/data/source" page "github.com/tidepool-org/platform/page" request "github.com/tidepool-org/platform/request" + gomock "go.uber.org/mock/gomock" ) // MockClient is a mock of Client interface. diff --git a/dexcom/fetch/test/runner_mocks.go b/dexcom/fetch/test/runner_mocks.go index 877940cbaf..78df30a5a6 100644 --- a/dexcom/fetch/test/runner_mocks.go +++ b/dexcom/fetch/test/runner_mocks.go @@ -14,8 +14,6 @@ import ( reflect "reflect" time "time" - gomock "go.uber.org/mock/gomock" - auth "github.com/tidepool-org/platform/auth" data "github.com/tidepool-org/platform/data" source "github.com/tidepool-org/platform/data/source" @@ -23,6 +21,7 @@ import ( fetch "github.com/tidepool-org/platform/dexcom/fetch" oauth "github.com/tidepool-org/platform/oauth" request "github.com/tidepool-org/platform/request" + gomock "go.uber.org/mock/gomock" ) // MockAuthClient is a mock of AuthClient interface. 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..6761af0f09 100644 --- a/env.test.sh +++ b/env.test.sh @@ -1,5 +1,5 @@ # Clear all TIDEPOOL_* environment variables -unset `env | cut -d'=' -f1 | grep '^TIDEPOOL_' | xargs` +unset $(env | cut -d'=' -f1 | grep '^TIDEPOOL_' | xargs) export TIDEPOOL_ENV="test" @@ -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 3f41fdeae3..5f23cf3c7e 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,14 @@ toolchain go1.24.3 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 github.com/blang/semver v3.5.1+incompatible github.com/deckarep/golang-set/v2 v2.8.0 github.com/githubnemo/CompileDaemon v1.4.0 + github.com/go-resty/resty/v2 v2.7.0 github.com/golang-jwt/jwt/v4 v4.5.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -63,6 +65,7 @@ require ( github.com/go-logr/logr v1.4.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect @@ -90,7 +93,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 @@ -98,6 +103,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/ugorji/go/codec v1.2.12 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect diff --git a/go.sum b/go.sum index 8341d8d4af..7ed5470602 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ 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= @@ -56,12 +58,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.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= github.com/golang-jwt/jwt/v4 v4.5.1/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= @@ -158,8 +164,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= @@ -182,6 +192,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/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -274,6 +286,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= @@ -293,6 +306,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= @@ -308,6 +322,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 e1eded2e14..75d7a2ae86 100644 --- a/permission/client/client.go +++ b/permission/client/client.go @@ -46,3 +46,92 @@ func (c *Client) GetUserPermissions(ctx context.Context, requestUserID string, t return permission.FixOwnerPermissions(result), nil } + +// GroupsForUser returns what users have shared permissions with the user with an id of granteeUserID. +// The GroupedPermissions are keyed by the id of the user who shared their permissions with granteeUserID. +func (c *Client) GroupsForUser(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) UsersInGroup(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) HasMembershipRelationship(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 +} + +func (c *Client) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) { + if granteeUserID != "" && granteeUserID == grantorUserID { + return true, nil + } + perms, err := c.GetUserPermissions(ctx, granteeUserID, grantorUserID) + if err != nil { + return false, err + } + if _, ok := perms[permission.Custodian]; ok { + return true, nil + } + if _, ok := perms[permission.Write]; ok { + return true, nil + } + if _, ok := perms[permission.Owner]; ok { + return true, nil + } + return false, nil +} diff --git a/permission/client_mock.go b/permission/client_mock.go new file mode 100644 index 0000000000..c2199bacab --- /dev/null +++ b/permission/client_mock.go @@ -0,0 +1,131 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/tidepool-org/platform/permission (interfaces: ExtendedClient) +// +// Generated by this command: +// +// mockgen -build_flags=--mod=mod -destination=./client_mock.go -package=permission . ExtendedClient +// + +// Package permission is a generated GoMock package. +package permission + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockExtendedClient is a mock of ExtendedClient interface. +type MockExtendedClient struct { + ctrl *gomock.Controller + recorder *MockExtendedClientMockRecorder + isgomock struct{} +} + +// MockExtendedClientMockRecorder is the mock recorder for MockExtendedClient. +type MockExtendedClientMockRecorder struct { + mock *MockExtendedClient +} + +// NewMockExtendedClient creates a new mock instance. +func NewMockExtendedClient(ctrl *gomock.Controller) *MockExtendedClient { + mock := &MockExtendedClient{ctrl: ctrl} + mock.recorder = &MockExtendedClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExtendedClient) EXPECT() *MockExtendedClientMockRecorder { + return m.recorder +} + +// GetUserPermissions mocks base method. +func (m *MockExtendedClient) 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 *MockExtendedClientMockRecorder) GetUserPermissions(ctx, requestUserID, targetUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPermissions", reflect.TypeOf((*MockExtendedClient)(nil).GetUserPermissions), ctx, requestUserID, targetUserID) +} + +// GroupsForUser mocks base method. +func (m *MockExtendedClient) GroupsForUser(ctx context.Context, granteeUserID string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupsForUser", ctx, granteeUserID) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GroupsForUser indicates an expected call of GroupsForUser. +func (mr *MockExtendedClientMockRecorder) GroupsForUser(ctx, granteeUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupsForUser", reflect.TypeOf((*MockExtendedClient)(nil).GroupsForUser), ctx, granteeUserID) +} + +// HasCustodianPermissions mocks base method. +func (m *MockExtendedClient) 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 *MockExtendedClientMockRecorder) HasCustodianPermissions(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasCustodianPermissions", reflect.TypeOf((*MockExtendedClient)(nil).HasCustodianPermissions), ctx, granteeUserID, grantorUserID) +} + +// HasMembershipRelationship mocks base method. +func (m *MockExtendedClient) HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasMembershipRelationship", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasMembershipRelationship indicates an expected call of HasMembershipRelationship. +func (mr *MockExtendedClientMockRecorder) HasMembershipRelationship(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasMembershipRelationship", reflect.TypeOf((*MockExtendedClient)(nil).HasMembershipRelationship), ctx, granteeUserID, grantorUserID) +} + +// HasWritePermissions mocks base method. +func (m *MockExtendedClient) HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HasWritePermissions", ctx, granteeUserID, grantorUserID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HasWritePermissions indicates an expected call of HasWritePermissions. +func (mr *MockExtendedClientMockRecorder) HasWritePermissions(ctx, granteeUserID, grantorUserID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasWritePermissions", reflect.TypeOf((*MockExtendedClient)(nil).HasWritePermissions), ctx, granteeUserID, grantorUserID) +} + +// UsersInGroup mocks base method. +func (m *MockExtendedClient) UsersInGroup(ctx context.Context, sharerID string) (Permissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UsersInGroup", ctx, sharerID) + ret0, _ := ret[0].(Permissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UsersInGroup indicates an expected call of UsersInGroup. +func (mr *MockExtendedClientMockRecorder) UsersInGroup(ctx, sharerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsersInGroup", reflect.TypeOf((*MockExtendedClient)(nil).UsersInGroup), ctx, sharerID) +} diff --git a/permission/permission.go b/permission/permission.go index b40a1bac80..ed217ce39e 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 ( @@ -19,6 +29,20 @@ type Client interface { GetUserPermissions(ctx context.Context, requestUserID string, targetUserID string) (Permissions, error) } +// ExtendedClient is a temporary interface that handles more involved permission checking as I refactor things after merging existing changes +// +//go:generate mockgen -build_flags=--mod=mod -destination=./client_mock.go -package=permission . ExtendedClient +type ExtendedClient interface { + Client + // GroupsForUser 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. + GroupsForUser(ctx context.Context, granteeUserID string) (Permissions, error) + // UsersInGroup returns permissions that the user with id sharerID has shared with others, keyed by user id. It includes the user themself. + UsersInGroup(ctx context.Context, sharerID string) (Permissions, error) + HasMembershipRelationship(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) + HasCustodianPermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) + HasWritePermissions(ctx context.Context, granteeUserID, grantorUserID string) (has bool, err error) +} + func FixOwnerPermissions(permissions Permissions) Permissions { if ownerPermission, ok := permissions[Owner]; ok { if _, ok = permissions[Write]; !ok { @@ -31,6 +55,20 @@ func FixOwnerPermissions(permissions Permissions) Permissions { return permissions } +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 +} + // HasExplicitMembershipRelationship return whether a grantor has given a // grantee explicit rights. This is need in some places where we want to test a // user's permission. It is called "Explicit" because in most middleware, a diff --git a/store/structured/mongo/config.go b/store/structured/mongo/config.go index a8ab8ae012..a5fc0a2f72 100644 --- a/store/structured/mongo/config.go +++ b/store/structured/mongo/config.go @@ -26,6 +26,12 @@ func LoadConfig() (*Config, error) { return cfg, err } +func LoadConfigPrefix(prefix string) (*Config, error) { + cfg := NewConfig() + err := cfg.LoadPrefix(prefix) + return cfg, err +} + // Config describe parameters need to make a connection to a Mongo database type Config struct { Scheme string `json:"scheme" envconfig:"TIDEPOOL_STORE_SCHEME"` @@ -72,7 +78,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/task/test/task_mocks.go b/task/test/task_mocks.go index 90877cfa52..ff5db426e6 100644 --- a/task/test/task_mocks.go +++ b/task/test/task_mocks.go @@ -13,10 +13,9 @@ import ( context "context" reflect "reflect" - gomock "go.uber.org/mock/gomock" - page "github.com/tidepool-org/platform/page" task "github.com/tidepool-org/platform/task" + gomock "go.uber.org/mock/gomock" ) // MockClient is a mock of Client interface. diff --git a/user/fallback_user_accessor.go b/user/fallback_user_accessor.go new file mode 100644 index 0000000000..af32851e27 --- /dev/null +++ b/user/fallback_user_accessor.go @@ -0,0 +1,123 @@ +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) FindUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + profile, _, err := f.findUserProfile(ctx, userID) + return profile, err +} + +func (f *FallbackLegacyUserAccessor) findUserProfile(ctx context.Context, userID string) (profile *LegacyUserProfile, retrievedFromSeagull bool, err error) { + seagullProfile, err := f.seagullLegacyAccessor.FindUserProfile(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.FindUserProfile(ctx, userID) + if err != nil { + return nil, false, err + } + if profile == nil { + return nil, false, ErrUserProfileNotFound + } + return profile, false, nil +} + +func (f *FallbackLegacyUserAccessor) UpdateUserProfile(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.upsertUserProfile(ctx, id, profile) + if errors.Is(err, ErrUserProfileMigrationInProgress) { + time.Sleep(time.Second * time.Duration(i+1)) + continue + } + if err != nil { + return err + } + } + return err +} + +func (f *FallbackLegacyUserAccessor) UpdateUserProfileV2(ctx context.Context, userID string, profile *UserProfile) error { + prevProfile, retrievedFromSeagull, err := f.findUserProfile(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 prevProfile != nil && retrievedFromSeagull && prevProfile.MigrationStatus == MigrationUnmigrated { + return ErrProfileNotMigrated + } + + return f.accessor.UpdateUserProfileV2(ctx, userID, profile) +} + +func (f *FallbackLegacyUserAccessor) upsertUserProfile(ctx context.Context, userID string, profile *LegacyUserProfile) error { + profile, retrievedFromSeagull, err := f.findUserProfile(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 profile != nil && retrievedFromSeagull && profile.MigrationStatus == MigrationUnmigrated { + return f.seagullLegacyAccessor.UpdateUserProfile(ctx, userID, profile) + } + + // 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.UpdateUserProfile(ctx, userID, profile) +} + +func (f *FallbackLegacyUserAccessor) DeleteUserProfile(ctx context.Context, userID string) error { + profile, retrievedFromSeagull, err := f.findUserProfile(ctx, userID) + if err != nil && !errors.Is(err, ErrUserProfileNotFound) { + return err + } + if profile != nil && retrievedFromSeagull && profile.MigrationStatus == MigrationUnmigrated { + return f.seagullLegacyAccessor.DeleteUserProfile(ctx, userID) + } + return f.accessor.DeleteUserProfile(ctx, userID) +} diff --git a/user/keycloak/client.go b/user/keycloak/client.go new file mode 100644 index 0000000000..d9ba2a79a2 --- /dev/null +++ b/user/keycloak/client.go @@ -0,0 +1,615 @@ +package keycloak + +import ( + "context" + "fmt" + "maps" + "net/http" + "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" + userLib "github.com/tidepool-org/platform/user" +) + +const ( + tokenPrefix = "kc" + tokenPartsSeparator = ":" + masterRealm = "master" + termsAcceptedAttribute = "terms_and_conditions" +) + +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) +} + +// keycloakUser is an intermediate user representation from FullModel to gocloak's model - though is this actually needed? Can it be removed entirely? +type keycloakUser struct { + ID string `json:"id"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled,omitempty"` + EmailVerified bool `json:"emailVerified,omitempty"` + Roles []string `json:"roles,omitempty"` + Attributes keycloakUserAttributes `json:"attributes"` +} + +type keycloakUserAttributes struct { + TermsAcceptedDate []string `json:"terms_and_conditions,omitempty"` + Profile *userLib.UserProfile `json:"profile"` +} + +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]interface{}{ + "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) (*keycloakUser, 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) (*keycloakUser, 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, user *keycloakUser) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + gocloakUser := gocloak.User{ + ID: &user.ID, + Username: &user.Username, + Enabled: &user.Enabled, + EmailVerified: &user.EmailVerified, + FirstName: &user.FirstName, + LastName: &user.LastName, + Email: &user.Email, + } + + attrs := map[string][]string{ + termsAcceptedAttribute: user.Attributes.TermsAcceptedDate, + } + if user.Attributes.Profile != nil { + profileAttrs := user.Attributes.Profile.ToAttributes() + maps.Copy(attrs, profileAttrs) + } + + gocloakUser.Attributes = &attrs + if err := c.keycloak.UpdateUser(ctx, token.AccessToken, c.cfg.Realm, gocloakUser); err != nil { + return err + } + if err := c.updateRolesForUser(ctx, user); err != nil { + return err + } + return nil +} + +func (c *keycloakClient) UpdateUserProfile(ctx context.Context, id string, p *userLib.UserProfile) error { + user, err := c.GetUserById(ctx, id) + if err != nil { + return err + } + if user == nil { + return userLib.ErrUserNotFound + } + user.Attributes.Profile = p + return c.UpdateUser(ctx, user) +} + +func (c *keycloakClient) DeleteUserProfile(ctx context.Context, id string) error { + user, err := c.GetUserById(ctx, id) + if err != nil { + return err + } + if user == nil { + return userLib.ErrUserNotFound + } + user.Attributes.Profile = nil + return c.UpdateUser(ctx, user) +} + +func (c *keycloakClient) FindUsersWithIds(ctx context.Context, ids []string) (users []*keycloakUser, 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([]*keycloakUser, len(res)) + for i, u := range res { + users[i] = newKeycloakUser(u) + } + + return users, nil +} + +func (c *keycloakClient) IntrospectToken(ctx context.Context, token oauth2.Token) (*userLib.TokenIntrospectionResult, error) { + clientId, clientSecret := c.getClientAndSecretFromToken(ctx, token) + + rtr, err := c.keycloak.RetrospectToken( + ctx, + token.AccessToken, + clientId, + clientSecret, + c.cfg.Realm, + ) + if err != nil { + return nil, err + } + + result := &userLib.TokenIntrospectionResult{ + Active: pointer.ToBool(rtr.Active), + } + if result.Active { + customClaims := &userLib.AccessTokenCustomClaims{} + _, err := c.keycloak.DecodeAccessTokenCustomClaims( + ctx, + token.AccessToken, + c.cfg.Realm, + customClaims, + ) + if err != nil { + return nil, err + } + result.Subject = customClaims.Subject + result.EmailVerified = customClaims.EmailVerified + result.ExpiresAt = customClaims.ExpiresAt.Unix() + result.RealmAccess = userLib.RealmAccess{ + Roles: customClaims.RealmAccess.Roles, + } + result.IdentityProvider = customClaims.IdentityProvider + } + + return result, 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, user *keycloakUser) error { + token, err := c.getAdminToken(ctx) + if err != nil { + return err + } + + 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, user.ID) + if err != nil { + return err + } + + var rolesToAdd []gocloak.Role + var rolesToDelete []gocloak.Role + + targetRoles := make(map[string]struct{}) + if len(user.Roles) > 0 { + for _, targetRoleName := range user.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 := userLib.ShorelineManagedRoles[*currentRole.Name]; ok { + rolesToDelete = append(rolesToDelete, *currentRole) + } + } + } + } + + if len(rolesToAdd) > 0 { + if err = c.keycloak.AddRealmRoleToUser(ctx, token.AccessToken, c.cfg.Realm, user.ID, rolesToAdd); err != nil { + return err + } + } + if len(rolesToDelete) > 0 { + if err = c.keycloak.DeleteRealmRoleFromUser(ctx, token.AccessToken, c.cfg.Realm, user.ID, 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 newKeycloakUser(gocloakUser *gocloak.User) *keycloakUser { + if gocloakUser == nil { + return nil + } + + user := &keycloakUser{ + ID: pointer.ToString(gocloakUser.ID), + Username: pointer.ToString(gocloakUser.Username), + FirstName: pointer.ToString(gocloakUser.FirstName), + LastName: pointer.ToString(gocloakUser.LastName), + Email: pointer.ToString(gocloakUser.Email), + EmailVerified: pointer.ToBool(gocloakUser.EmailVerified), + Enabled: pointer.ToBool(gocloakUser.Enabled), + } + var roles []string + if gocloakUser.RealmRoles != nil { + roles = *gocloakUser.RealmRoles + user.Roles = *gocloakUser.RealmRoles + } + if gocloakUser.Attributes != nil { + attrs := *gocloakUser.Attributes + if ts, ok := attrs[termsAcceptedAttribute]; ok { + user.Attributes.TermsAcceptedDate = ts + } + if prof, ok := userLib.ProfileFromAttributes(pointer.ToString(gocloakUser.Username), attrs, roles); ok { + user.Attributes.Profile = prof + } + } + + return user +} + +func newUserFromKeycloakUser(keycloakUser *keycloakUser) *userLib.User { + var termsAcceptedDate *string + attrs := keycloakUser.Attributes + if len(attrs.TermsAcceptedDate) > 0 { + if ts, err := userLib.UnixStringToTimestamp(attrs.TermsAcceptedDate[0]); err == nil { + termsAcceptedDate = &ts + } + } + + user := &userLib.User{ + UserID: pointer.FromString(keycloakUser.ID), + Username: pointer.FromString(keycloakUser.Username), + Emails: []string{keycloakUser.Email}, + Roles: pointer.FromStringArray(keycloakUser.Roles), + TermsAccepted: termsAcceptedDate, + EmailVerified: pointer.FromBool(keycloakUser.EmailVerified), + IsMigrated: true, + Enabled: keycloakUser.Enabled, + Profile: attrs.Profile, + } + + // All non-custodial users have a password and it's important to set the hash to a non-empty value. + // When users are serialized by this service, the payload contains a flag `passwordExists` that + // is computed based on the presence of a password hash in the user struct. This flag is used by + // other services (e.g. hydrophone) to determine whether the user is custodial or not. + if !user.IsCustodialAccount() { + user.PwHash = "true" + } + + return user +} + +func userToKeycloakUser(u *userLib.User) *keycloakUser { + keycloakUser := &keycloakUser{ + ID: pointer.ToString(u.UserID), + Username: strings.ToLower(pointer.ToString(u.Username)), + Email: strings.ToLower(u.Email()), + Enabled: u.IsEnabled(), + EmailVerified: pointer.ToBool(u.EmailVerified), + Roles: pointer.ToStringArray(u.Roles), + Attributes: keycloakUserAttributes{}, + } + if len(keycloakUser.Roles) == 0 { + keycloakUser.Roles = []string{userLib.RolePatient} + } + if !u.IsMigrated && u.PwHash == "" && !u.HasRole(userLib.RoleCustodialAccount) { + keycloakUser.Roles = append(keycloakUser.Roles, userLib.RoleCustodialAccount) + } + if u.TermsAccepted != nil { + if termsAccepted, err := userLib.TimestampToUnixString(*u.TermsAccepted); err == nil { + keycloakUser.Attributes.TermsAcceptedDate = []string{termsAccepted} + } + } + if u.Profile != nil { + keycloakUser.Attributes.Profile = u.Profile + } + + return keycloakUser +} + +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 +} diff --git a/user/keycloak/user_accessor.go b/user/keycloak/user_accessor.go new file mode 100644 index 0000000000..9bcb218768 --- /dev/null +++ b/user/keycloak/user_accessor.go @@ -0,0 +1,121 @@ +package keycloak + +import ( + "context" + "time" + + "golang.org/x/oauth2" + + "github.com/tidepool-org/platform/pointer" + userLib "github.com/tidepool-org/platform/user" +) + +type keycloakUserAccessor struct { + cfg *KeycloakConfig + adminToken *oauth2.Token + adminTokenRefreshExpires time.Time + keycloakClient *keycloakClient +} + +func NewKeycloakUserAccessor(config *KeycloakConfig) *keycloakUserAccessor { + newKeycloakClient(config) + return &keycloakUserAccessor{ + cfg: config, + keycloakClient: newKeycloakClient(config), + } +} + +func (m *keycloakUserAccessor) FindUser(ctx context.Context, user *userLib.User) (*userLib.User, error) { + var keycloakUser *keycloakUser + var err error + + if userLib.IsValidUserID(pointer.ToString(user.UserID)) { + keycloakUser, err = m.keycloakClient.GetUserById(ctx, pointer.ToString(user.UserID)) + } else { + email := "" + if len(user.Emails) > 0 { + email = user.Emails[0] + } + keycloakUser, err = m.keycloakClient.GetUserByEmail(ctx, email) + } + + if err != nil && err != userLib.ErrUserNotFound { + return nil, err + } else if err == nil && keycloakUser != nil { + return newUserFromKeycloakUser(keycloakUser), nil + } + // All users should be migrated into keycloak by the time this code is released. + return nil, userLib.ErrUserNotMigrated +} + +func (m *keycloakUserAccessor) FindUserById(ctx context.Context, id string) (*userLib.User, error) { + if !userLib.IsValidUserID(id) { + return nil, userLib.ErrUserNotFound + } + + keycloakUser, err := m.keycloakClient.GetUserById(ctx, id) + if err != nil { + return nil, err + } + if keycloakUser == nil { + return nil, userLib.ErrUserNotFound + } + return newUserFromKeycloakUser(keycloakUser), nil +} + +func (m *keycloakUserAccessor) FindUserProfile(ctx context.Context, id string) (*userLib.LegacyUserProfile, error) { + user, err := m.FindUserById(ctx, id) + if err != nil { + return nil, err + } + if user == nil { + return nil, userLib.ErrUserProfileNotFound + } + return user.Profile.ToLegacyProfile(pointer.ToStringArray(user.Roles)), nil +} + +func (m *keycloakUserAccessor) FindUserProfileV2(ctx context.Context, id string) (*userLib.UserProfile, error) { + user, err := m.FindUserById(ctx, id) + if err != nil { + return nil, err + } + if user == nil { + return nil, userLib.ErrUserProfileNotFound + } + return user.Profile, 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 []*userLib.User, err error) { + keycloakUsers, err := m.keycloakClient.FindUsersWithIds(ctx, ids) + if err != nil { + return users, err + } + + for _, user := range keycloakUsers { + users = append(users, newUserFromKeycloakUser(user)) + } + return users, nil +} + +func (m *keycloakUserAccessor) UpdateUserProfile(ctx context.Context, userID string, p *userLib.LegacyUserProfile) error { + roles, err := m.Roles(ctx, userID) + if err != nil { + return err + } + if !userLib.HasClinicOrClinicianRole(roles) && p.Clinic != nil { + p.Clinic = nil + } + return m.keycloakClient.UpdateUserProfile(ctx, userID, p.ToUserProfile()) +} + +func (m *keycloakUserAccessor) UpdateUserProfileV2(ctx context.Context, userID string, p *userLib.UserProfile) error { + return m.keycloakClient.UpdateUserProfile(ctx, userID, p) +} + +func (m *keycloakUserAccessor) DeleteUserProfile(ctx context.Context, userId string) error { + return m.keycloakClient.DeleteUserProfile(ctx, userId) +} diff --git a/user/legacy_raw_seagull_profile.go b/user/legacy_raw_seagull_profile.go new file mode 100644 index 0000000000..535558fedb --- /dev/null +++ b/user/legacy_raw_seagull_profile.go @@ -0,0 +1,150 @@ +package user + +import ( + "cmp" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/tidepool-org/platform/pointer" +) + +var ( + ErrSeagullFieldNotFound = errors.New("seagull field not found within value object string") + 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. TODO: +// delete once all profiles are migrated over +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..73dc1f8b4a --- /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 + +// UserProfile represents the user modifiable attributes of a user. It is named +// somewhat redundantly as UserProfile instead of Profile because there already +// exists a type Profile in this package. +type UserProfile 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"` + Email string `json:"-"` // This is used when returning profiles in the legacy format. It is not stored in the profile, but is populated from the keycloak username and not returned in the new profiles route. +} + +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 *UserProfile) IsPatientProfile(roles []string) bool { + return HasPatientRole(roles) || up.hasPatientFields() || !HasClinicOrClinicianRole(roles) +} + +func (up *UserProfile) 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 *UserProfile) IsClinicianProfile(roles []string) bool { + return up.Clinic != nil || HasClinicOrClinicianRole(roles) +} + +func (up *UserProfile) 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, + } + if up.Email != "" && !IsUnclaimedCustodialEmail(up.Email) { + legacyProfile.Patient.Email = up.Email + legacyProfile.Patient.Emails = []string{up.Email} + legacyProfile.Email = up.Email + legacyProfile.Emails = []string{up.Email} + } + } + // 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 +} + +// ClearPatientInfo makes a copy of up, clearing out certain patient information - this is called usually due to lack of permissions to the patient information +func (up *UserProfile) ClearPatientInfo() *UserProfile { + // explicitly specifying the type to make sure it's a value instead of pointer + var newProfile UserProfile = *up + newProfile.Birthday = "" + newProfile.DiagnosisDate = "" + newProfile.TargetDevices = nil + newProfile.TargetTimezone = "" + newProfile.About = "" + newProfile.MRN = "" + newProfile.BiologicalSex = "" + newProfile.Custodian = nil + newProfile.Clinic = nil + return &newProfile +} + +func (p *LegacyUserProfile) ToUserProfile() *UserProfile { + up := &UserProfile{ + 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 +} + +// 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:"-"` + // The Email and Emails fields are legacy properties that will be populated from the keycloak user if the profile is finished migrating, otherwise from the seagull collection + Email string `json:"email,omitempty"` + Emails []string `json:"emails,omitempty"` +} + +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"` + // The Email and Emails fields are legacy properties that will be populated from the keycloak user if the profile is finished migrating, otherwise from the seagull collection + Email string `json:"email,omitempty"` + Emails []string `json:"emails,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 *UserProfile) 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 +} + +func ProfileFromAttributes(username string, attributes map[string][]string, roles []string) (profile *UserProfile, ok bool) { + up := &UserProfile{ + Email: username, + } + if val := getAttribute(attributes, "full_name"); val != "" { + up.FullName = val + ok = true + } + if val := getAttribute(attributes, "custodian_full_name"); val != "" { + up.Custodian = &Custodian{ + FullName: val, + } + ok = true + } + if val := getAttribute(attributes, "birthday"); val != "" { + up.Birthday = Date(val) + ok = true + } + if val := getAttribute(attributes, "diagnosis_date"); val != "" { + up.DiagnosisDate = Date(val) + ok = true + } + if val := getAttribute(attributes, "diagnosis_type"); val != "" { + up.DiagnosisType = val + ok = true + } + if vals := getAttributes(attributes, "target_devices"); len(vals) > 0 { + up.TargetDevices = vals + ok = true + } + if val := getAttribute(attributes, "target_timezone"); val != "" { + up.TargetTimezone = val + ok = true + } + if val := getAttribute(attributes, "about"); val != "" { + up.About = val + ok = true + } + if val := getAttribute(attributes, "mrn"); val != "" { + up.MRN = val + ok = true + } + if val := getAttribute(attributes, "biological_sex"); val != "" { + up.BiologicalSex = val + ok = 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 + ok = true + } + + return up, ok +} + +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 *UserProfile) 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 *UserProfile) 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) +} diff --git a/user/profile_test.go b/user/profile_test.go new file mode 100644 index 0000000000..c28bdc8d14 --- /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.UserProfile, legacyProfile *user.LegacyUserProfile, roles []string) { + Expect(profile.ToLegacyProfile(roles)).To(BeComparableTo(legacyProfile)) + }, + Entry("Regular patient", &user.UserProfile{ + 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.UserProfile{ + 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.UserProfile{ + 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..2ba13bca43 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 { 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/timeutil.go b/user/timeutil.go new file mode 100644 index 0000000000..a0e6cf5109 --- /dev/null +++ b/user/timeutil.go @@ -0,0 +1,30 @@ +package user + +import ( + "fmt" + "strconv" + "time" +) + +func ParseTimestamp(timestamp string) (time.Time, error) { + return time.Parse(TimestampFormat, timestamp) +} + +func TimestampToUnixString(timestamp string) (unix string, err error) { + parsed, err := ParseTimestamp(timestamp) + if err != nil { + return + } + unix = fmt.Sprintf("%v", parsed.Unix()) + return +} + +func UnixStringToTimestamp(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/user.go b/user/user.go index bd5ea2e927..7c8d9f130e 100644 --- a/user/user.go +++ b/user/user.go @@ -3,18 +3,28 @@ package user import ( "context" "regexp" + "slices" + "strings" "time" "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" ) const ( - RoleClinic = "clinic" + RoleClinic = "clinic" + RoleClinician = "clinician" + RoleCustodialAccount = "custodial_account" + RoleMigratedClinic = "migrated_clinic" + RolePatient = "patient" + RoleBrokered = "brokered" ) +var custodialAccountRegexp = regexp.MustCompile(`(?i)unclaimed-custodial-automation\+\d+@tidepool\.org`) + func Roles() []string { return []string{ RoleClinic, @@ -26,11 +36,28 @@ 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" 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"` + Emails []string `json:"emails,omitempty" bson:"emails,omitempty"` + PwHash string `json:"-" bson:"pwhash,omitempty"` + Hash string `json:"-" bson:"userhash,omitempty"` + IsMigrated bool `json:"-" bson:"-"` + IsUnclaimedCustodial bool `json:"-" bson:"-"` + Enabled bool `json:"-" bson:"-"` + CreatedTime string `json:"createdTime,omitempty" bson:"createdTime,omitempty"` + CreatedUserID string `json:"createdUserId,omitempty" bson:"createdUserId,omitempty"` + ModifiedTime string `json:"modifiedTime,omitempty" bson:"modifiedTime,omitempty"` + ModifiedUserID string `json:"modifiedUserId,omitempty" bson:"modifiedUserId,omitempty"` + DeletedTime string `json:"deletedTime,omitempty" bson:"deletedTime,omitempty"` + DeletedUserID string `json:"deletedUserId,omitempty" bson:"deletedUserId,omitempty"` + Profile *UserProfile `json:"profile,omitempty" bson:"-"` + PasswordExists *bool `json:"passwordExists,omitempty" bson:"-"` + // The following 2 properties are only returned for the route that returns users that have shared their data w/ another user + TrustorPermissions *permission.Permission `json:"trustorPermissions,omitempty" bson:"-"` + TrusteePermissions *permission.Permission `json:"trusteePermissions,omitempty" bson:"-"` } func (u *User) Parse(parser structure.ObjectParser) { @@ -52,11 +79,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 } @@ -68,16 +91,62 @@ 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) { u.Username = nil u.EmailVerified = nil u.TermsAccepted = nil u.Roles = nil + u.PasswordExists = nil } return nil } +func (u *User) Email() string { + if u.Username != nil { + return strings.ToLower(*u.Username) + } + return "" +} + +// IsClinic returns true if the user is legacy clinic Account +func (u *User) IsClinic() bool { + return u.HasRole(RoleClinic) +} + +func (u *User) IsCustodialAccount() bool { + return u.HasRole(RoleCustodialAccount) +} + +// IsClinician returns true if the user is a clinician +func (u *User) IsClinician() bool { + return u.HasRole(RoleClinician) +} + +func (u *User) AreTermsAccepted() bool { + if u.TermsAccepted == nil { + return false + } + _, err := TimestampToUnixString(*u.TermsAccepted) + return err == nil +} + +func (u *User) IsEnabled() bool { + if u.IsMigrated { + return u.Enabled + } + return u.PwHash != "" && !u.IsDeleted() +} + +func (u *User) IsDeleted() bool { + // mdb only? + return u.DeletedTime != "" +} + type UserArray []*User func (u UserArray) Sanitize(details request.AuthDetails) error { @@ -111,3 +180,9 @@ func ValidateID(value string) error { } 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 { + ok, _ := regexp.MatchString(`^([a-fA-F0-9]{10})$|^([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})$`, id) + return ok +} diff --git a/user/user_accessor.go b/user/user_accessor.go new file mode 100644 index 0000000000..41ce534e18 --- /dev/null +++ b/user/user_accessor.go @@ -0,0 +1,90 @@ +package user + +import ( + "context" + "errors" + + "github.com/Nerzal/gocloak/v13/pkg/jwx" +) + +const ( + serverRole = "backend_service" + + TimestampFormat = "2006-01-02T15:04:05-07:00" +) + +//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") + ErrUserConflict = errors.New("user already exists") + ErrEmailConflict = errors.New("email already exists") + 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 { + FindUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) + UpdateUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) error + DeleteUserProfile(ctx context.Context, userID string) error +} + +type ProfileAccessor interface { + LegacyProfileAccessor + UpdateUserProfileV2(ctx context.Context, userID string, p *UserProfile) 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 + FindUser(ctx context.Context, user *User) (*User, error) + FindUserById(ctx context.Context, id string) (*User, error) + FindUsersWithIds(ctx context.Context, ids []string) ([]*User, error) +} + +type TokenIntrospectionResult struct { + Active bool `json:"active"` + Subject string `json:"sub"` + EmailVerified bool `json:"email_verified"` + ExpiresAt int64 `json:"eat"` + RealmAccess RealmAccess `json:"realm_access"` + IdentityProvider string `json:"identityProvider"` +} + +type AccessTokenCustomClaims struct { + jwx.Claims + IdentityProvider string `json:"identity_provider,omitempty"` +} + +type RealmAccess struct { + Roles []string `json:"roles"` +} + +func (t *TokenIntrospectionResult) IsServerToken() bool { + if len(t.RealmAccess.Roles) > 0 { + for _, role := range t.RealmAccess.Roles { + if role == serverRole { + return true + } + } + } + + return false +} diff --git a/user/user_mock.go b/user/user_mock.go new file mode 100644 index 0000000000..52b3844242 --- /dev/null +++ b/user/user_mock.go @@ -0,0 +1,239 @@ +// 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 +} + +// DeleteUserProfile mocks base method. +func (m *MockProfileAccessor) DeleteUserProfile(ctx context.Context, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserProfile", ctx, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserProfile indicates an expected call of DeleteUserProfile. +func (mr *MockProfileAccessorMockRecorder) DeleteUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserProfile", reflect.TypeOf((*MockProfileAccessor)(nil).DeleteUserProfile), ctx, userID) +} + +// FindUserProfile mocks base method. +func (m *MockProfileAccessor) FindUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserProfile", ctx, userID) + ret0, _ := ret[0].(*LegacyUserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserProfile indicates an expected call of FindUserProfile. +func (mr *MockProfileAccessorMockRecorder) FindUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserProfile", reflect.TypeOf((*MockProfileAccessor)(nil).FindUserProfile), ctx, userID) +} + +// UpdateUserProfile mocks base method. +func (m *MockProfileAccessor) UpdateUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) 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) +} + +// UpdateUserProfileV2 mocks base method. +func (m *MockProfileAccessor) UpdateUserProfileV2(ctx context.Context, userID string, p *UserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfileV2", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfileV2 indicates an expected call of UpdateUserProfileV2. +func (mr *MockProfileAccessorMockRecorder) UpdateUserProfileV2(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfileV2", reflect.TypeOf((*MockProfileAccessor)(nil).UpdateUserProfileV2), 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 +} + +// DeleteUserProfile mocks base method. +func (m *MockUserAccessor) DeleteUserProfile(ctx context.Context, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserProfile", ctx, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserProfile indicates an expected call of DeleteUserProfile. +func (mr *MockUserAccessorMockRecorder) DeleteUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).DeleteUserProfile), ctx, userID) +} + +// FindUser mocks base method. +func (m *MockUserAccessor) FindUser(ctx context.Context, user *User) (*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUser", ctx, user) + ret0, _ := ret[0].(*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUser indicates an expected call of FindUser. +func (mr *MockUserAccessorMockRecorder) FindUser(ctx, user any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUser", reflect.TypeOf((*MockUserAccessor)(nil).FindUser), ctx, user) +} + +// FindUserById mocks base method. +func (m *MockUserAccessor) FindUserById(ctx context.Context, id string) (*User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserById", ctx, id) + ret0, _ := ret[0].(*User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserById indicates an expected call of FindUserById. +func (mr *MockUserAccessorMockRecorder) FindUserById(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserById", reflect.TypeOf((*MockUserAccessor)(nil).FindUserById), ctx, id) +} + +// FindUserProfile mocks base method. +func (m *MockUserAccessor) FindUserProfile(ctx context.Context, userID string) (*LegacyUserProfile, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserProfile", ctx, userID) + ret0, _ := ret[0].(*LegacyUserProfile) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserProfile indicates an expected call of FindUserProfile. +func (mr *MockUserAccessorMockRecorder) FindUserProfile(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserProfile", reflect.TypeOf((*MockUserAccessor)(nil).FindUserProfile), 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) +} + +// 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) +} + +// UpdateUserProfile mocks base method. +func (m *MockUserAccessor) UpdateUserProfile(ctx context.Context, userID string, p *LegacyUserProfile) 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) +} + +// UpdateUserProfileV2 mocks base method. +func (m *MockUserAccessor) UpdateUserProfileV2(ctx context.Context, userID string, p *UserProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserProfileV2", ctx, userID, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserProfileV2 indicates an expected call of UpdateUserProfileV2. +func (mr *MockUserAccessorMockRecorder) UpdateUserProfileV2(ctx, userID, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfileV2", reflect.TypeOf((*MockUserAccessor)(nil).UpdateUserProfileV2), ctx, userID, p) +}