Skip to content

Commit

Permalink
Add Patch Users (#390)
Browse files Browse the repository at this point in the history
* add patch users

* readme

* format

* fix typing
  • Loading branch information
nmacianx authored Jul 9, 2024
1 parent f7ddb42 commit a6ad181
Show file tree
Hide file tree
Showing 5 changed files with 244 additions and 8 deletions.
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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="[email protected]",
email="[email protected]",
display_name="Desmond Copeland",
)

# Update explicit data for a user rather than overriding all fields
descope_client.mgmt.user.update_login_id(
login_id="[email protected]",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions descope/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions descope/management/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
114 changes: 113 additions & 1 deletion descope/management/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
94 changes: 94 additions & 0 deletions tests/management/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 protected]",
)

# 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="[email protected]",
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": "[email protected]",
"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:
Expand Down

0 comments on commit a6ad181

Please sign in to comment.