diff --git a/descope/api/client.go b/descope/api/client.go index 77ed2a7d..1a02a466 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -102,6 +102,7 @@ var ( userCreate: "mgmt/user/create", userCreateBatch: "mgmt/user/create/batch", userUpdate: "mgmt/user/update", + userPatch: "mgmt/user/patch", userDelete: "mgmt/user/delete", userDeleteAllTestUsers: "mgmt/user/test/delete/all", userImport: "mgmt/user/import", @@ -291,6 +292,7 @@ type mgmtEndpoints struct { userCreate string userCreateBatch string userUpdate string + userPatch string userDelete string userDeleteAllTestUsers string userImport string @@ -691,6 +693,10 @@ func (e *endpoints) ManagementUserUpdate() string { return path.Join(e.version, e.mgmt.userUpdate) } +func (e *endpoints) ManagementUserPatch() string { + return path.Join(e.version, e.mgmt.userPatch) +} + func (e *endpoints) ManagementUserDelete() string { return path.Join(e.version, e.mgmt.userDelete) } @@ -1191,6 +1197,10 @@ func (c *Client) DoPostRequest(ctx context.Context, uri string, body interface{} return c.doRequestWithBody(ctx, http.MethodPost, uri, body, options, pswd) } +func (c *Client) DoPatchRequest(ctx context.Context, uri string, body interface{}, options *HTTPRequest, pswd string) (*HTTPResponse, error) { + return c.doRequestWithBody(ctx, http.MethodPatch, uri, body, options, pswd) +} + func (c *Client) doRequestWithBody(ctx context.Context, method string, uri string, body interface{}, options *HTTPRequest, pswd string) (*HTTPResponse, error) { if options == nil { options = &HTTPRequest{} diff --git a/descope/internal/mgmt/user.go b/descope/internal/mgmt/user.go index 7f8885a5..e186bce2 100644 --- a/descope/internal/mgmt/user.go +++ b/descope/internal/mgmt/user.go @@ -143,6 +143,21 @@ func (u *user) Update(ctx context.Context, loginID string, user *descope.UserReq return unmarshalUserResponse(res) } +func (u *user) Patch(ctx context.Context, loginID string, user *descope.PatchUserRequest) (*descope.UserResponse, error) { + if loginID == "" { + return nil, utils.NewInvalidArgumentError("loginID") + } + if user == nil { + return nil, utils.NewInvalidArgumentError("user") + } + req := makePatchUserRequest(loginID, user) + res, err := u.client.DoPatchRequest(ctx, api.Routes.ManagementUserPatch(), req, nil, u.conf.ManagementKey) + if err != nil { + return nil, err + } + return unmarshalUserResponse(res) +} + func (u *user) Delete(ctx context.Context, loginID string) error { if loginID == "" { return utils.NewInvalidArgumentError("loginID") @@ -759,6 +774,52 @@ func makeUpdateUserRequest(req *createUserRequest) map[string]any { return res } +func makePatchUserRequest(loginID string, req *descope.PatchUserRequest) map[string]any { + res := map[string]interface{}{ + "loginId": loginID, + } + if req.Name != nil { + res["name"] = *req.Name + } + if req.GivenName != nil { + res["givenName"] = *req.GivenName + } + if req.MiddleName != nil { + res["middleName"] = *req.MiddleName + } + if req.FamilyName != nil { + res["familyName"] = *req.FamilyName + } + if req.Phone != nil { + res["phone"] = *req.Phone + } + if req.Email != nil { + res["email"] = *req.Email + } + if req.Roles != nil { + res["roleNames"] = *req.Roles + } + if req.Tenants != nil { + res["userTenants"] = makeAssociatedTenantList(*req.Tenants) + } + if req.CustomAttributes != nil { + res["customAttributes"] = req.CustomAttributes + } + if req.Picture != nil { + res["picture"] = *req.Picture + } + if req.VerifiedEmail != nil { + res["verifiedEmail"] = *req.VerifiedEmail + } + if req.VerifiedPhone != nil { + res["verifiedPhone"] = *req.VerifiedPhone + } + if req.SSOAppIDs != nil { + res["ssoAppIds"] = *req.SSOAppIDs + } + return res +} + func makeUpdateUserTenantRequest(loginID, tenantID string) map[string]any { return map[string]any{ "loginId": loginID, diff --git a/descope/internal/mgmt/user_test.go b/descope/internal/mgmt/user_test.go index 0ac081ef..03fc6248 100644 --- a/descope/internal/mgmt/user_test.go +++ b/descope/internal/mgmt/user_test.go @@ -339,6 +339,172 @@ func TestUserUpdateError(t *testing.T) { require.Error(t, err) } +func TestUserPatchError(t *testing.T) { + m := newTestMgmt(nil, helpers.DoOk(nil)) + user := &descope.PatchUserRequest{} + email := "foo@bar.com" + user.Email = &email + _, err := m.User().Patch(context.Background(), "", user) + require.Error(t, err) + _, err = m.User().Patch(context.Background(), "abc", nil) + require.Error(t, err) +} + +func TestUserPatchSuccess(t *testing.T) { + response := map[string]any{ + "user": map[string]any{ + "name": "name1", + "middleName": "middleName1", + "phone": "+9724567890", + "verifiedPhone": true, + "picture": "https://test.com", + "roleNames": []string{"foo", "bar"}, + }} + m := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "abc", req["loginId"]) + + require.Equal(t, "name1", req["name"]) + require.Equal(t, "middleName1", req["middleName"]) + require.Equal(t, "+9724567890", req["phone"]) + reqVerifiedPhone := req["verifiedPhone"].(bool) + require.True(t, reqVerifiedPhone) + require.Equal(t, "https://test.com", req["picture"]) + roles := req["roleNames"].([]any) + require.EqualValues(t, []any{"foo", "bar"}, roles) + + _, ok := req["givenName"] + require.False(t, ok) + _, ok = req["familyName"] + require.False(t, ok) + _, ok = req["email"] + require.False(t, ok) + _, ok = req["userTenants"] + require.False(t, ok) + _, ok = req["customAttributes"] + require.False(t, ok) + _, ok = req["verifiedEmail"] + require.False(t, ok) + _, ok = req["ssoAppIds"] + require.False(t, ok) + }, response)) + user := &descope.PatchUserRequest{} + patchedName := "name1" + patchedMiddleName := "middleName1" + patchedPhone := "+9724567890" + patchedVerifiedPhone := true + patchedPicture := "https://test.com" + patchedRoles := []string{"foo", "bar"} + user.Name = &patchedName + user.MiddleName = &patchedMiddleName + user.Phone = &patchedPhone + user.VerifiedPhone = &patchedVerifiedPhone + user.Picture = &patchedPicture + user.Roles = &patchedRoles + res, err := m.User().Patch(context.Background(), "abc", user) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, "name1", res.Name) + require.Equal(t, "middleName1", res.MiddleName) + require.Equal(t, "+9724567890", res.Phone) + require.True(t, res.VerifiedPhone) + require.Equal(t, "https://test.com", res.Picture) + require.EqualValues(t, patchedRoles, res.RoleNames) +} + +func TestUserPatchSuccess2(t *testing.T) { + response := map[string]any{ + "user": map[string]any{ + "givenName": "givenName1", + "familyName": "familyName1", + "email": "foo@bar.com", + "verifiedEmail": true, + "customAttributes": map[string]interface{}{ + "ca1": "cavalue1", + }, + "ssoAppIds": []string{"app1", "app2"}, + }} + m := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.Equal(t, "abc", req["loginId"]) + + require.Equal(t, "givenName1", req["givenName"]) + require.Equal(t, "familyName1", req["familyName"]) + require.Equal(t, "foo@bar.com", req["email"]) + reqVerifiedEmail := req["verifiedEmail"].(bool) + require.True(t, reqVerifiedEmail) + require.EqualValues(t, map[string]interface{}{ + "ca1": "cavalue1", + }, req["customAttributes"]) + ssoAppIDs := req["ssoAppIds"].([]any) + require.EqualValues(t, []any{"app1", "app2"}, ssoAppIDs) + userTenants := req["userTenants"].([]any) + require.Len(t, userTenants, 2) + for i := range userTenants { + tenant := userTenants[i].(map[string]any) + roleNames := tenant["roleNames"].([]any) + if i == 0 { + require.Equal(t, "t1", tenant["tenantId"]) + require.Len(t, roleNames, 2) + require.Equal(t, "foo", roleNames[0]) + require.Equal(t, "foo2", roleNames[1]) + } else { + require.Equal(t, "t2", tenant["tenantId"]) + require.Len(t, roleNames, 1) + require.Equal(t, "bar", roleNames[0]) + } + } + + _, ok := req["name"] + require.False(t, ok) + _, ok = req["middleName"] + require.False(t, ok) + _, ok = req["phone"] + require.False(t, ok) + _, ok = req["picture"] + require.False(t, ok) + _, ok = req["verifiedPhone"] + require.False(t, ok) + _, ok = req["roleNames"] + require.False(t, ok) + }, response)) + user := &descope.PatchUserRequest{} + patchedGivenName := "givenName1" + patchedFamilyName := "familyName1" + patchedEmail := "foo@bar.com" + patchedVerifiedEmail := true + patchedCustomAttributes := map[string]interface{}{ + "ca1": "cavalue1", + } + patchedTenants := []*descope.AssociatedTenant{ + {TenantID: "t1", Roles: []string{"foo", "foo2"}}, + {TenantID: "t2", Roles: []string{"bar"}}, + } + patchedSSOAppIDs := []string{"app1", "app2"} + user.GivenName = &patchedGivenName + user.FamilyName = &patchedFamilyName + user.Email = &patchedEmail + user.VerifiedEmail = &patchedVerifiedEmail + user.CustomAttributes = patchedCustomAttributes + user.Tenants = &patchedTenants + user.SSOAppIDs = &patchedSSOAppIDs + res, err := m.User().Patch(context.Background(), "abc", user) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, "givenName1", res.GivenName) + require.Equal(t, "familyName1", res.FamilyName) + require.Equal(t, "foo@bar.com", res.Email) + require.True(t, res.VerifiedEmail) + require.EqualValues(t, map[string]interface{}{ + "ca1": "cavalue1", + }, res.CustomAttributes) + require.EqualValues(t, []string{"app1", "app2"}, res.SSOAppIDs) +} + func TestUserDeleteSuccess(t *testing.T) { m := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") diff --git a/descope/sdk/mgmt.go b/descope/sdk/mgmt.go index 077eda42..4007323f 100644 --- a/descope/sdk/mgmt.go +++ b/descope/sdk/mgmt.go @@ -181,8 +181,14 @@ type User interface { // // IMPORTANT: All parameters will override whatever values are currently set // in the existing user. Use carefully. + // Instead, use Patch if you don't want to pass all parameters. Update(ctx context.Context, loginID string, user *descope.UserRequest) (*descope.UserResponse, error) + // Patches an existing user. + // + // Only the fields that are set in the request will be updated. + Patch(ctx context.Context, loginID string, user *descope.PatchUserRequest) (*descope.UserResponse, error) + // Delete an existing user. // // IMPORTANT: This action is irreversible. Use carefully. diff --git a/descope/tests/mocks/mgmt/managementmock.go b/descope/tests/mocks/mgmt/managementmock.go index 9a292d94..a810c497 100644 --- a/descope/tests/mocks/mgmt/managementmock.go +++ b/descope/tests/mocks/mgmt/managementmock.go @@ -255,6 +255,10 @@ type MockUser struct { UpdateResponse *descope.UserResponse UpdateError error + PatchAssert func(loginID string, user *descope.PatchUserRequest) + PatchResponse *descope.UserResponse + PatchError error + DeleteAssert func(loginID string) DeleteError error @@ -434,6 +438,13 @@ func (m *MockUser) Update(_ context.Context, loginID string, user *descope.UserR return m.UpdateResponse, m.UpdateError } +func (m *MockUser) Patch(_ context.Context, loginID string, user *descope.PatchUserRequest) (*descope.UserResponse, error) { + if m.PatchAssert != nil { + m.PatchAssert(loginID, user) + } + return m.PatchResponse, m.PatchError +} + func (m *MockUser) Delete(_ context.Context, loginID string) error { if m.DeleteAssert != nil { m.DeleteAssert(loginID) diff --git a/descope/types.go b/descope/types.go index 0e946af9..82dd6912 100644 --- a/descope/types.go +++ b/descope/types.go @@ -392,6 +392,22 @@ type UserRequest struct { SSOAppIDs []string `json:"ssoAppIDs,omitempty"` } +type PatchUserRequest struct { + Name *string `json:"name,omitempty"` + GivenName *string `json:"givenName,omitempty"` + MiddleName *string `json:"middleName,omitempty"` + FamilyName *string `json:"familyName,omitempty"` + Phone *string `json:"phone,omitempty"` + Email *string `json:"email,omitempty"` + Roles *[]string `json:"roles,omitempty"` + Tenants *[]*AssociatedTenant `json:"tenants,omitempty"` + CustomAttributes map[string]any `json:"customAttributes,omitempty"` + Picture *string `json:"picture,omitempty"` + VerifiedEmail *bool `json:"verifiedEmail,omitempty"` + VerifiedPhone *bool `json:"verifiedPhone,omitempty"` + SSOAppIDs *[]string `json:"ssoAppIds,omitempty"` +} + type BatchUser struct { LoginID string `json:"loginId,omitempty"` Password *BatchUserPassword `json:"password,omitempty"`