Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BACK-2780] Add new user profiles endpoint. #698

Open
wants to merge 62 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
7b25f7a
Refactoring and adding shoreline models to platform/user for auth.
lostlevels Feb 7, 2024
76a6bb5
Cleanup.
lostlevels Feb 7, 2024
0b9f170
Add /v1/profiles/:userId route.
lostlevels Feb 12, 2024
a93f797
Renaming.
lostlevels Feb 12, 2024
5dbd8ac
Add the route.
lostlevels Feb 12, 2024
6a9014c
Fix build.
lostlevels Feb 12, 2024
ed826a9
Fix test.
lostlevels Feb 12, 2024
2a49fb0
Use permissions like in seagull.
lostlevels Feb 14, 2024
b1fdbd3
Validate the profile.
lostlevels Feb 15, 2024
73cb026
HasWritePermissions.
lostlevels Feb 21, 2024
7d32881
Rename profile routes to be consistent w/ existing ones.
lostlevels Mar 17, 2024
5d56462
Use snakecase attributes for now but don't flatten yet as blip is still
lostlevels Mar 27, 2024
82db89b
Have a LegacyUserProfile to support seagull requests.
lostlevels Apr 1, 2024
9e9f798
Change leagcy profile routes for simpler proxying in routetable.
lostlevels Apr 1, 2024
650b693
Add legacy delete route.
lostlevels Apr 1, 2024
523b23e
Move keycloak client and keycloak user_accessor to own package.
lostlevels Apr 1, 2024
d17ab19
Rename to package keycloak.
lostlevels Apr 1, 2024
e468cbb
Add user profile config for keycloak 24+.
lostlevels Apr 1, 2024
36570c4
Remove user profile config as that's handled in TF.
lostlevels Apr 2, 2024
deb91a7
Add custodian field to profile.
lostlevels Apr 2, 2024
4f40f4b
Allow services to retrieve user profile.
lostlevels Apr 11, 2024
49c2809
Use "dummy" attribute "profile_has_custodian" for easier keycloak
lostlevels Apr 16, 2024
b0b6163
patient.fullName is only set for fake children.
lostlevels Apr 19, 2024
97ce75c
Remove "profile_" prefix from profile keycloak attributes. Add
lostlevels Apr 23, 2024
cdb875c
Use right json.
lostlevels Apr 29, 2024
62a9368
Delete unused shoreline code. Move user.FullUser into user.User.
lostlevels Apr 30, 2024
5efb6ee
Remove unused hasher code.
lostlevels Apr 30, 2024
4670d48
Remove unused fields.
lostlevels Apr 30, 2024
992ea71
Add MRN attribute.
lostlevels Apr 30, 2024
904e276
Copy amoeba's permissions with regards to membership and custodian.
lostlevels May 1, 2024
41a1fae
Remove check from route since part of middleware now.
lostlevels May 1, 2024
8e5598f
Remove unneeded comment.
lostlevels May 29, 2024
d3f829a
Add GroupsForUser as a prelude to some seagull / gatekeeper
lostlevels Jun 5, 2024
942e210
Commence "old" seagull routes that retrieves from the seagull collection
lostlevels Jun 5, 2024
97d74df
Update migration status.
lostlevels Jun 5, 2024
6721784
Make sure seagull.value field is preserved properly during updates and
lostlevels Jun 7, 2024
1354fa9
Use fallback profile accessor to check for profile first in seagull.
lostlevels Jun 11, 2024
3195e7b
Rename repository for clarity of purpose.
lostlevels Jun 11, 2024
e993dd1
Bump gocloak.
lostlevels Jun 19, 2024
3f9eb22
role field.
lostlevels Jun 19, 2024
af7234f
Omit profile fields if empty in response.
lostlevels Jun 20, 2024
196a609
Add clinic profile fields.
lostlevels Jun 24, 2024
c0a9513
Add normalizer methods for profiles.
lostlevels Jun 25, 2024
07508bb
Account for empty profile fullName.
lostlevels Jul 8, 2024
50f373b
[BACK-3046] Create initial shared users with profiles path w/o
lostlevels Jul 10, 2024
55f0007
Start metadata/users/:userid/users filter params.
lostlevels Jul 10, 2024
c8d9d7f
Parse users profiles query filter.
lostlevels Jul 10, 2024
a2bfbbb
Update users route to properly filter out users. Document Permission /
lostlevels Jul 15, 2024
c80234d
Remove unused query filter on users profiles.
lostlevels Jul 17, 2024
2247fc0
Handle email and emails in legacy seagull profiles.
lostlevels Jul 30, 2024
d78c148
Read raw value as map from seagull value.
lostlevels Jul 31, 2024
3401464
Allow setting of profile on seagull document's value field.
lostlevels Jul 31, 2024
e4f607e
Migrate diagnosisType.
lostlevels Jul 31, 2024
f4316d6
Remove email field from clinic as confirmed only a few fake clinic pr…
lostlevels Aug 6, 2024
83222e2
Use correct FullName in case of fake children.
lostlevels Aug 7, 2024
621062b
Handle certain incorrect types in legacy seagull profile.
lostlevels Aug 8, 2024
2908e3c
Fix some logic and tests for profiles.
lostlevels Aug 12, 2024
aba0355
Set max profile field length to equal keycloak < 24
lostlevels Aug 12, 2024
47e7e45
Make some profile values pointers so that some legacy migration profiles
lostlevels Aug 14, 2024
7bd8a85
Export MaxProfileFieldLen
lostlevels Aug 14, 2024
eeee757
Remove unused field, add tests, synchronize keycloak access.
lostlevels Aug 15, 2024
2b1f2fb
Use existing UsersArray type, add update tests.
lostlevels Aug 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions auth/service/api/v1/permission.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
255 changes: 255 additions & 0 deletions auth/service/api/v1/profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package v1

import (
"context"
stdErrs "errors"
"maps"
"net/http"
"sync"

"github.com/ant0ine/go-json-rest/rest"
"golang.org/x/sync/errgroup"

"github.com/tidepool-org/platform/permission"
"github.com/tidepool-org/platform/request"
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.UserProfile, error) {
// Until seagull migration is complete use UserProfileAccessor() to get a profile instead of the profile within the user itself.
profile, err := r.UserProfileAccessor().FindUserProfile(ctx, userID)
if err != nil {
return nil, err
}
if profile == nil {
return nil, user.ErrUserProfileNotFound
}
// Once seagull migration is compelte, we can return
// the profile attached to the user directly via person.Profile
// through r.UserAccessor().FindUserProfile
return profile, nil
}

func (r *Router) GetProfile(res rest.ResponseWriter, req *rest.Request) {
responder := request.MustNewResponder(res, req)
ctx := req.Context()
userID := req.PathParam("userId")
if r.handledUserNotExists(ctx, responder, userID) {
return
}

profile, err := r.getProfile(ctx, userID)
if err != nil {
r.handleProfileErr(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")
if r.handledUserNotExists(ctx, responder, targetUserID) {
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.User, 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
}
profile, err := r.getProfile(ctx, userID)
if stdErrs.Is(err, user.ErrUserProfileNotFound) || profile == nil {
return nil
}
if err != nil {
return err
}
trustorPerms := trustPerms.TrustorPermissions
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settings are not part of the profile object so probably there's no need to do anything?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the settings and preferences returned in the old seagull code, but don't know if they are actually used by clients. @krystophv @clintonium-119 @gniezen Do you know if the returned result of paltform-client getAssociatedUsersDetails ever uses the returned users' settings or preferences in blip or uploader?

}
if trustorPerms.Has(permission.Custodian) {
// TODO: need to read seagull.value.preferences - confirm this is actually used
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preferences are not part of the profile object so probably there's no need to do anything?

}
}
sharedUser.Profile = profile
sharedUser.TrusteePermissions = trustPerms.TrusteePermissions
sharedUser.TrustorPermissions = trustPerms.TrustorPermissions
// Seems no sharedUser.Sanitize call to filter out "protected" fields in seagull except sanitizeUser to remove "passwordExists" field - which doesn't exist in current platform/user.User
lock.Lock()
results = append(results, sharedUser)
lock.Unlock()
return nil
})
}
if err := group.Wait(); err != nil {
r.handleProfileErr(responder, err)
return
}

responder.Data(http.StatusOK, results)
}

func (r *Router) GetLegacyProfile(res rest.ResponseWriter, req *rest.Request) {
responder := request.MustNewResponder(res, req)
ctx := req.Context()
userID := req.PathParam("userId")
if r.handledUserNotExists(ctx, responder, userID) {
return
}

profile, err := r.getProfile(ctx, userID)
if err != nil {
r.handleProfileErr(responder, err)
return
}
responder.Data(http.StatusOK, profile.ToLegacyProfile())
}

func (r *Router) UpdateProfile(res rest.ResponseWriter, req *rest.Request) {
responder := request.MustNewResponder(res, req)

profile := &user.UserProfile{}
if err := request.DecodeRequestBody(req.Request, profile); err != nil {
responder.Error(http.StatusBadRequest, err)
return
}
r.updateProfile(res, req, profile)
}

func (r *Router) UpdateLegacyProfile(res rest.ResponseWriter, req *rest.Request) {
responder := request.MustNewResponder(res, req)

profile := &user.LegacyUserProfile{}
if err := request.DecodeRequestBody(req.Request, profile); err != nil {
responder.Error(http.StatusBadRequest, err)
return
}
r.updateProfile(res, req, profile.ToUserProfile())
}

func (r *Router) updateProfile(res rest.ResponseWriter, req *rest.Request, profile *user.UserProfile) {
responder := request.MustNewResponder(res, req)
ctx := req.Context()
userID := req.PathParam("userId")
if err := structValidator.New().Validate(profile); err != nil {
responder.Error(http.StatusBadRequest, err)
return
}
if r.handledUserNotExists(ctx, responder, userID) {
return
}
// Once seagull migration is complete, we can use r.UserAccessor().UpdateUserProfile.
if err := r.UserProfileAccessor().UpdateUserProfile(ctx, userID, profile); err != nil {
r.handleProfileErr(responder, err)
return
}
responder.Empty(http.StatusOK)
}

func (r *Router) DeleteProfile(res rest.ResponseWriter, req *rest.Request) {
responder := request.MustNewResponder(res, req)
ctx := req.Context()
userID := req.PathParam("userId")

err := r.UserProfileAccessor().DeleteUserProfile(ctx, userID)
if err != nil {
r.handleProfileErr(responder, err)
return
}
responder.Empty(http.StatusOK)
}

func (r *Router) handleProfileErr(responder *request.Responder, err error) {
switch {
case stdErrs.Is(err, user.ErrUserNotFound), stdErrs.Is(err, user.ErrUserProfileNotFound):
responder.Empty(http.StatusNotFound)
return
default:
responder.InternalServerError(err)
}
}

func (r *Router) handledUserNotExists(ctx context.Context, responder *request.Responder, userID string) (handled bool) {
person, err := r.UserAccessor().FindUserById(ctx, userID)
if err != nil {
r.handleProfileErr(responder, err)
return true
}
if person == nil {
responder.Empty(http.StatusNotFound)
return true
}
return false
}
1 change: 1 addition & 0 deletions auth/service/api/v1/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func (r *Router) Routes() []*rest.Route {
r.ProviderSessionsRoutes(),
r.RestrictedTokensRoutes(),
r.DeviceCheckRoutes(),
r.ProfileRoutes(),
}
acc := make([]*rest.Route, 0)
for _, r := range routes {
Expand Down
Loading