diff --git a/README.md b/README.md index 4a1a5f8c..268e57b2 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ These sections show how to use the SDK to perform permission and user management 8. [Manage Flows](#manage-flows-and-theme) 9. [Manage JWTs](#manage-jwts) 10. [Impersonate](#impersonate) -12. [Embedded links](#embedded-links) -13. [Audit](#audit) -14. [Manage ReBAC Authz](#manage-rebac-authz) -15. [Manage Project](#manage-project) -16. [Manage SSO Applications](#manage-sso-applications) +11. [Embedded links](#embedded-links) +12. [Audit](#audit) +13. [Manage ReBAC Authz](#manage-rebac-authz) +14. [Manage Project](#manage-project) +15. [Manage SSO Applications](#manage-sso-applications) If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. @@ -472,6 +472,7 @@ descope_client.logout_all(refresh_token) ``` ### History + You can get the current session user history. The request requires a valid refresh token. @@ -545,7 +546,7 @@ tenants = tenants_resp["tenants"] ### Manage Users -You can create, update, delete or load users, as well as setting new password, expire password and search according to filters: +You can create, update, patch, delete or load users, as well as setting new password, expire password and search according to filters: ```Python # A user must have a login ID, other fields are optional. @@ -604,6 +605,13 @@ descope_client.mgmt.user.update( sso_app_ids=["appId1"], ) +# Patch will override only the set fields in the user +descope_client.mgmt.user.patch( + login_id="desmond@descope.com", + email="desmond@descope.com", + display_name="Desmond Copeland", +) + # Update explicit data for a user rather than overriding all fields descope_client.mgmt.user.update_login_id( login_id="desmond@descope.com", @@ -732,6 +740,7 @@ descope_client.mgmt.access_key.delete("key-id") ``` Exchange the access key and provide optional access key login options: + ```python loc = AccessKeyLoginOptions(custom_claims={"k1": "v1"}) jwt_response = descope_client.exchange_access_key( @@ -1290,7 +1299,7 @@ descope_client.mgmt.project.import_project(export) You can create, update, delete or load sso applications: -```Python +```python # Create OIDC SSO application descope_client.mgmt.sso_application.create_oidc_application( name="My First sso app", @@ -1338,6 +1347,7 @@ apps_resp = descope_client.mgmt.sso_application.load_all() apps = apps_resp["apps"] for app in apps: # Do something +``` ### Utils for your end to end (e2e) tests and integration tests diff --git a/descope/auth.py b/descope/auth.py index a7f015c0..3ea207f6 100644 --- a/descope/auth.py +++ b/descope/auth.py @@ -155,6 +155,25 @@ def do_post( self._raise_from_response(response) return response + def do_patch( + self, + uri: str, + body: dict | list[dict] | list[str] | None, + params=None, + pswd: str | None = None, + ) -> requests.Response: + response = requests.patch( + f"{self.base_url}{uri}", + headers=self._get_default_headers(pswd), + json=body, + allow_redirects=False, + verify=self.secure, + params=params, + timeout=self.timeout_seconds, + ) + self._raise_from_response(response) + return response + def do_delete( self, uri: str, params=None, pswd: str | None = None ) -> requests.Response: diff --git a/descope/management/common.py b/descope/management/common.py index 7b753003..36f1a5e2 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -23,6 +23,7 @@ class MgmtV1: user_create_path = "/v1/mgmt/user/create" user_create_batch_path = "/v1/mgmt/user/create/batch" user_update_path = "/v1/mgmt/user/update" + user_patch_path = "/v1/mgmt/user/patch" user_delete_path = "/v1/mgmt/user/delete" user_logout_path = "/v1/mgmt/user/logout" user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all" diff --git a/descope/management/user.py b/descope/management/user.py index 11c391fe..439536e6 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import Any, List, Optional, Union from descope._auth_base import AuthBase from descope.auth import Auth @@ -327,12 +327,16 @@ def update( """ Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides to the existing user. Empty fields will override populated fields. Use carefully. + Use `patch` for partial updates instead. Args: login_id (str): The login ID of the user to update. email (str): Optional user email address. phone (str): Optional user phone number. display_name (str): Optional user display name. + given_name (str): Optional user given name. + middle_name (str): Optional user middle name. + family_name (str): Optional user family name. role_names (List[str]): An optional list of the user's roles without tenant association. These roles are mutually exclusive with the `user_tenant` roles. user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are @@ -371,6 +375,66 @@ def update( pswd=self._auth.management_key, ) + def patch( + self, + login_id: str, + email: Optional[str] = None, + phone: Optional[str] = None, + display_name: Optional[str] = None, + given_name: Optional[str] = None, + middle_name: Optional[str] = None, + family_name: Optional[str] = None, + role_names: Optional[List[str]] = None, + user_tenants: Optional[List[AssociatedTenant]] = None, + picture: Optional[str] = None, + custom_attributes: Optional[dict] = None, + verified_email: Optional[bool] = None, + verified_phone: Optional[bool] = None, + sso_app_ids: Optional[List[str]] = None, + ): + """ + Patches an existing user with the given various fields. Only the given fields will be used to update the user. + + Args: + login_id (str): The login ID of the user to update. + email (str): Optional user email address. + phone (str): Optional user phone number. + display_name (str): Optional user display name. + given_name (str): Optional user given name. + middle_name (str): Optional user middle name. + family_name (str): Optional user family name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles. + user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`. + picture (str): Optional url for user picture + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + + Raise: + AuthException: raised if patch operation fails + """ + self._auth.do_patch( + MgmtV1.user_patch_path, + User._compose_patch_body( + login_id, + email, + phone, + display_name, + given_name, + middle_name, + family_name, + role_names, + user_tenants, + picture, + custom_attributes, + verified_email, + verified_phone, + sso_app_ids, + ), + pswd=self._auth.management_key, + ) + def delete( self, login_id: str, @@ -1616,3 +1680,51 @@ def _compose_update_body( if seed is not None: res["seed"] = seed return res + + @staticmethod + def _compose_patch_body( + login_id: str, + email: Optional[str], + phone: Optional[str], + display_name: Optional[str], + given_name: Optional[str], + middle_name: Optional[str], + family_name: Optional[str], + role_names: Optional[List[str]], + user_tenants: Optional[List[AssociatedTenant]], + picture: Optional[str], + custom_attributes: Optional[dict], + verified_email: Optional[bool], + verified_phone: Optional[bool], + sso_app_ids: Optional[List[str]], + ) -> dict: + res: dict[str, Any] = { + "loginId": login_id, + } + if email is not None: + res["email"] = email + if phone is not None: + res["phone"] = phone + if display_name is not None: + res["displayName"] = display_name + if given_name is not None: + res["givenName"] = given_name + if middle_name is not None: + res["middleName"] = middle_name + if family_name is not None: + res["familyName"] = family_name + if role_names is not None: + res["roleNames"] = role_names + if user_tenants is not None: + res["userTenants"] = associated_tenants_to_dict(user_tenants) + if picture is not None: + res["picture"] = picture + if custom_attributes is not None: + res["customAttributes"] = custom_attributes + if verified_email is not None: + res["verifiedEmail"] = verified_email + if verified_phone is not None: + res["verifiedPhone"] = verified_phone + if sso_app_ids is not None: + res["ssoAppIds"] = sso_app_ids + return res diff --git a/tests/management/test_user.py b/tests/management/test_user.py index 0200a4c4..81e73a9e 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -513,6 +513,100 @@ def test_update(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_patch(self): + # Test failed flows + with patch("requests.patch") as mock_patch: + mock_patch.return_value.ok = False + self.assertRaises( + AuthException, + self.client.mgmt.user.patch, + "valid-id", + "email@something.com", + ) + + # Test success flow with some params set + with patch("requests.patch") as mock_patch: + mock_patch.return_value.ok = True + self.assertIsNone( + self.client.mgmt.user.patch( + "id", + display_name="new-name", + email=None, + phone=None, + given_name=None, + role_names=["domain.com"], + user_tenants=None, + picture="https://test.com", + custom_attributes={"ak": "av"}, + sso_app_ids=["app1", "app2"], + ) + ) + mock_patch.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={ + "loginId": "id", + "displayName": "new-name", + "roleNames": ["domain.com"], + "picture": "https://test.com", + "customAttributes": {"ak": "av"}, + "ssoAppIds": ["app1", "app2"], + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + # Test success flow with other params + with patch("requests.patch") as mock_patch: + mock_patch.return_value.ok = True + self.assertIsNone( + self.client.mgmt.user.patch( + "id", + email="a@test.com", + phone="+123456789", + given_name="given", + middle_name="middle", + family_name="family", + role_names=None, + user_tenants=[ + AssociatedTenant("tenant1"), + AssociatedTenant("tenant2", ["role1", "role2"]), + ], + custom_attributes=None, + verified_email=True, + verified_phone=False, + ) + ) + mock_patch.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + }, + params=None, + json={ + "loginId": "id", + "email": "a@test.com", + "phone": "+123456789", + "givenName": "given", + "middleName": "middle", + "familyName": "family", + "verifiedEmail": True, + "verifiedPhone": False, + "userTenants": [ + {"tenantId": "tenant1", "roleNames": []}, + {"tenantId": "tenant2", "roleNames": ["role1", "role2"]}, + ], + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_delete(self): # Test failed flows with patch("requests.post") as mock_post: