Skip to content

Commit a6ad181

Browse files
authored
Add Patch Users (#390)
* add patch users * readme * format * fix typing
1 parent f7ddb42 commit a6ad181

File tree

5 files changed

+244
-8
lines changed

5 files changed

+244
-8
lines changed

README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ These sections show how to use the SDK to perform permission and user management
6767
8. [Manage Flows](#manage-flows-and-theme)
6868
9. [Manage JWTs](#manage-jwts)
6969
10. [Impersonate](#impersonate)
70-
12. [Embedded links](#embedded-links)
71-
13. [Audit](#audit)
72-
14. [Manage ReBAC Authz](#manage-rebac-authz)
73-
15. [Manage Project](#manage-project)
74-
16. [Manage SSO Applications](#manage-sso-applications)
70+
11. [Embedded links](#embedded-links)
71+
12. [Audit](#audit)
72+
13. [Manage ReBAC Authz](#manage-rebac-authz)
73+
14. [Manage Project](#manage-project)
74+
15. [Manage SSO Applications](#manage-sso-applications)
7575

7676
If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section.
7777

@@ -472,6 +472,7 @@ descope_client.logout_all(refresh_token)
472472
```
473473

474474
### History
475+
475476
You can get the current session user history.
476477
The request requires a valid refresh token.
477478

@@ -545,7 +546,7 @@ tenants = tenants_resp["tenants"]
545546

546547
### Manage Users
547548

548-
You can create, update, delete or load users, as well as setting new password, expire password and search according to filters:
549+
You can create, update, patch, delete or load users, as well as setting new password, expire password and search according to filters:
549550

550551
```Python
551552
# A user must have a login ID, other fields are optional.
@@ -604,6 +605,13 @@ descope_client.mgmt.user.update(
604605
sso_app_ids=["appId1"],
605606
)
606607

608+
# Patch will override only the set fields in the user
609+
descope_client.mgmt.user.patch(
610+
login_id="[email protected]",
611+
612+
display_name="Desmond Copeland",
613+
)
614+
607615
# Update explicit data for a user rather than overriding all fields
608616
descope_client.mgmt.user.update_login_id(
609617
login_id="[email protected]",
@@ -732,6 +740,7 @@ descope_client.mgmt.access_key.delete("key-id")
732740
```
733741

734742
Exchange the access key and provide optional access key login options:
743+
735744
```python
736745
loc = AccessKeyLoginOptions(custom_claims={"k1": "v1"})
737746
jwt_response = descope_client.exchange_access_key(
@@ -1290,7 +1299,7 @@ descope_client.mgmt.project.import_project(export)
12901299

12911300
You can create, update, delete or load sso applications:
12921301

1293-
```Python
1302+
```python
12941303
# Create OIDC SSO application
12951304
descope_client.mgmt.sso_application.create_oidc_application(
12961305
name="My First sso app",
@@ -1338,6 +1347,7 @@ apps_resp = descope_client.mgmt.sso_application.load_all()
13381347
apps = apps_resp["apps"]
13391348
for app in apps:
13401349
# Do something
1350+
```
13411351

13421352
### Utils for your end to end (e2e) tests and integration tests
13431353

descope/auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ def do_post(
155155
self._raise_from_response(response)
156156
return response
157157

158+
def do_patch(
159+
self,
160+
uri: str,
161+
body: dict | list[dict] | list[str] | None,
162+
params=None,
163+
pswd: str | None = None,
164+
) -> requests.Response:
165+
response = requests.patch(
166+
f"{self.base_url}{uri}",
167+
headers=self._get_default_headers(pswd),
168+
json=body,
169+
allow_redirects=False,
170+
verify=self.secure,
171+
params=params,
172+
timeout=self.timeout_seconds,
173+
)
174+
self._raise_from_response(response)
175+
return response
176+
158177
def do_delete(
159178
self, uri: str, params=None, pswd: str | None = None
160179
) -> requests.Response:

descope/management/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class MgmtV1:
2323
user_create_path = "/v1/mgmt/user/create"
2424
user_create_batch_path = "/v1/mgmt/user/create/batch"
2525
user_update_path = "/v1/mgmt/user/update"
26+
user_patch_path = "/v1/mgmt/user/patch"
2627
user_delete_path = "/v1/mgmt/user/delete"
2728
user_logout_path = "/v1/mgmt/user/logout"
2829
user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all"

descope/management/user.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Optional, Union
1+
from typing import Any, List, Optional, Union
22

33
from descope._auth_base import AuthBase
44
from descope.auth import Auth
@@ -327,12 +327,16 @@ def update(
327327
"""
328328
Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides
329329
to the existing user. Empty fields will override populated fields. Use carefully.
330+
Use `patch` for partial updates instead.
330331
331332
Args:
332333
login_id (str): The login ID of the user to update.
333334
email (str): Optional user email address.
334335
phone (str): Optional user phone number.
335336
display_name (str): Optional user display name.
337+
given_name (str): Optional user given name.
338+
middle_name (str): Optional user middle name.
339+
family_name (str): Optional user family name.
336340
role_names (List[str]): An optional list of the user's roles without tenant association. These roles are
337341
mutually exclusive with the `user_tenant` roles.
338342
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(
371375
pswd=self._auth.management_key,
372376
)
373377

378+
def patch(
379+
self,
380+
login_id: str,
381+
email: Optional[str] = None,
382+
phone: Optional[str] = None,
383+
display_name: Optional[str] = None,
384+
given_name: Optional[str] = None,
385+
middle_name: Optional[str] = None,
386+
family_name: Optional[str] = None,
387+
role_names: Optional[List[str]] = None,
388+
user_tenants: Optional[List[AssociatedTenant]] = None,
389+
picture: Optional[str] = None,
390+
custom_attributes: Optional[dict] = None,
391+
verified_email: Optional[bool] = None,
392+
verified_phone: Optional[bool] = None,
393+
sso_app_ids: Optional[List[str]] = None,
394+
):
395+
"""
396+
Patches an existing user with the given various fields. Only the given fields will be used to update the user.
397+
398+
Args:
399+
login_id (str): The login ID of the user to update.
400+
email (str): Optional user email address.
401+
phone (str): Optional user phone number.
402+
display_name (str): Optional user display name.
403+
given_name (str): Optional user given name.
404+
middle_name (str): Optional user middle name.
405+
family_name (str): Optional user family name.
406+
role_names (List[str]): An optional list of the user's roles without tenant association. These roles are
407+
mutually exclusive with the `user_tenant` roles.
408+
user_tenants (List[AssociatedTenant]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are
409+
mutually exclusive with the general `role_names`.
410+
picture (str): Optional url for user picture
411+
custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app
412+
sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user.
413+
414+
Raise:
415+
AuthException: raised if patch operation fails
416+
"""
417+
self._auth.do_patch(
418+
MgmtV1.user_patch_path,
419+
User._compose_patch_body(
420+
login_id,
421+
email,
422+
phone,
423+
display_name,
424+
given_name,
425+
middle_name,
426+
family_name,
427+
role_names,
428+
user_tenants,
429+
picture,
430+
custom_attributes,
431+
verified_email,
432+
verified_phone,
433+
sso_app_ids,
434+
),
435+
pswd=self._auth.management_key,
436+
)
437+
374438
def delete(
375439
self,
376440
login_id: str,
@@ -1616,3 +1680,51 @@ def _compose_update_body(
16161680
if seed is not None:
16171681
res["seed"] = seed
16181682
return res
1683+
1684+
@staticmethod
1685+
def _compose_patch_body(
1686+
login_id: str,
1687+
email: Optional[str],
1688+
phone: Optional[str],
1689+
display_name: Optional[str],
1690+
given_name: Optional[str],
1691+
middle_name: Optional[str],
1692+
family_name: Optional[str],
1693+
role_names: Optional[List[str]],
1694+
user_tenants: Optional[List[AssociatedTenant]],
1695+
picture: Optional[str],
1696+
custom_attributes: Optional[dict],
1697+
verified_email: Optional[bool],
1698+
verified_phone: Optional[bool],
1699+
sso_app_ids: Optional[List[str]],
1700+
) -> dict:
1701+
res: dict[str, Any] = {
1702+
"loginId": login_id,
1703+
}
1704+
if email is not None:
1705+
res["email"] = email
1706+
if phone is not None:
1707+
res["phone"] = phone
1708+
if display_name is not None:
1709+
res["displayName"] = display_name
1710+
if given_name is not None:
1711+
res["givenName"] = given_name
1712+
if middle_name is not None:
1713+
res["middleName"] = middle_name
1714+
if family_name is not None:
1715+
res["familyName"] = family_name
1716+
if role_names is not None:
1717+
res["roleNames"] = role_names
1718+
if user_tenants is not None:
1719+
res["userTenants"] = associated_tenants_to_dict(user_tenants)
1720+
if picture is not None:
1721+
res["picture"] = picture
1722+
if custom_attributes is not None:
1723+
res["customAttributes"] = custom_attributes
1724+
if verified_email is not None:
1725+
res["verifiedEmail"] = verified_email
1726+
if verified_phone is not None:
1727+
res["verifiedPhone"] = verified_phone
1728+
if sso_app_ids is not None:
1729+
res["ssoAppIds"] = sso_app_ids
1730+
return res

tests/management/test_user.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,100 @@ def test_update(self):
513513
timeout=DEFAULT_TIMEOUT_SECONDS,
514514
)
515515

516+
def test_patch(self):
517+
# Test failed flows
518+
with patch("requests.patch") as mock_patch:
519+
mock_patch.return_value.ok = False
520+
self.assertRaises(
521+
AuthException,
522+
self.client.mgmt.user.patch,
523+
"valid-id",
524+
525+
)
526+
527+
# Test success flow with some params set
528+
with patch("requests.patch") as mock_patch:
529+
mock_patch.return_value.ok = True
530+
self.assertIsNone(
531+
self.client.mgmt.user.patch(
532+
"id",
533+
display_name="new-name",
534+
email=None,
535+
phone=None,
536+
given_name=None,
537+
role_names=["domain.com"],
538+
user_tenants=None,
539+
picture="https://test.com",
540+
custom_attributes={"ak": "av"},
541+
sso_app_ids=["app1", "app2"],
542+
)
543+
)
544+
mock_patch.assert_called_with(
545+
f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}",
546+
headers={
547+
**common.default_headers,
548+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
549+
},
550+
params=None,
551+
json={
552+
"loginId": "id",
553+
"displayName": "new-name",
554+
"roleNames": ["domain.com"],
555+
"picture": "https://test.com",
556+
"customAttributes": {"ak": "av"},
557+
"ssoAppIds": ["app1", "app2"],
558+
},
559+
allow_redirects=False,
560+
verify=True,
561+
timeout=DEFAULT_TIMEOUT_SECONDS,
562+
)
563+
# Test success flow with other params
564+
with patch("requests.patch") as mock_patch:
565+
mock_patch.return_value.ok = True
566+
self.assertIsNone(
567+
self.client.mgmt.user.patch(
568+
"id",
569+
570+
phone="+123456789",
571+
given_name="given",
572+
middle_name="middle",
573+
family_name="family",
574+
role_names=None,
575+
user_tenants=[
576+
AssociatedTenant("tenant1"),
577+
AssociatedTenant("tenant2", ["role1", "role2"]),
578+
],
579+
custom_attributes=None,
580+
verified_email=True,
581+
verified_phone=False,
582+
)
583+
)
584+
mock_patch.assert_called_with(
585+
f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}",
586+
headers={
587+
**common.default_headers,
588+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
589+
},
590+
params=None,
591+
json={
592+
"loginId": "id",
593+
"email": "[email protected]",
594+
"phone": "+123456789",
595+
"givenName": "given",
596+
"middleName": "middle",
597+
"familyName": "family",
598+
"verifiedEmail": True,
599+
"verifiedPhone": False,
600+
"userTenants": [
601+
{"tenantId": "tenant1", "roleNames": []},
602+
{"tenantId": "tenant2", "roleNames": ["role1", "role2"]},
603+
],
604+
},
605+
allow_redirects=False,
606+
verify=True,
607+
timeout=DEFAULT_TIMEOUT_SECONDS,
608+
)
609+
516610
def test_delete(self):
517611
# Test failed flows
518612
with patch("requests.post") as mock_post:

0 commit comments

Comments
 (0)