diff --git a/README.md b/README.md index 8fcce21f7..5bd176c71 100644 --- a/README.md +++ b/README.md @@ -780,6 +780,36 @@ users_history_resp = descope_client.mgmt.user.history(["user-id-1", "user-id-2"] # Do something ``` +#### User Impersonation Consent + +When using the User Impersonation feature with consent validation, user objects returned from `load()`, `load_by_user_id()`, and `search_all()` methods will include a `consentExpiration` field (Unix timestamp in seconds). This field indicates when the user's consent for impersonation expires, allowing you to: + +- Identify which users have granted impersonation consent +- Filter users by consent status +- Track consent expiration times + +```Python +# Load a user and check consent expiration +user_resp = descope_client.mgmt.user.load("desmond@descope.com") +user = user_resp["user"] +consent_expiration = user.get("consentExpiration") # Unix timestamp or None + +if consent_expiration: + print(f"User has granted consent until: {consent_expiration}") + +# Search users and filter by consent status +users_resp = descope_client.mgmt.user.search_all() +users_with_consent = [u for u in users_resp["users"] if u.get("consentExpiration")] + +# The consentExpiration field is also available in UserObj for batch operations +from descope import UserObj +user_obj = UserObj( + login_id="desmond@descope.com", + email="desmond@descope.com", + consent_expiration=1735689600, # Optional Unix timestamp +) +``` + #### Set or Expire User Password You can set a new active password for a user that they can sign in with. diff --git a/descope/management/user.py b/descope/management/user.py index 9b3ed8885..52e0444b1 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -34,6 +34,7 @@ def __init__( password: Optional[UserPassword] = None, seed: Optional[str] = None, status: Optional[str] = None, + consent_expiration: Optional[int] = None, ): self.login_id = login_id self.email = email @@ -53,6 +54,7 @@ def __init__( self.password = password self.seed = seed self.status = status + self.consent_expiration = consent_expiration class CreateUserObj: @@ -1082,7 +1084,12 @@ def update_email( """ response = self._http.post( MgmtV1.user_update_email_path, - body={"loginId": login_id, "email": email, "verified": verified, "failOnConflict": fail_on_conflict}, + body={ + "loginId": login_id, + "email": email, + "verified": verified, + "failOnConflict": fail_on_conflict, + }, ) return response.json() @@ -1112,7 +1119,12 @@ def update_phone( """ response = self._http.post( MgmtV1.user_update_phone_path, - body={"loginId": login_id, "phone": phone, "verified": verified, "failOnConflict": fail_on_conflict}, + body={ + "loginId": login_id, + "phone": phone, + "verified": verified, + "failOnConflict": fail_on_conflict, + }, ) return response.json() @@ -2026,6 +2038,7 @@ def _compose_patch_body( sso_app_ids: Optional[List[str]], status: Optional[str], test: bool = False, + consent_expiration: Optional[int] = None, ) -> dict: res: dict[str, Any] = { "loginId": login_id, @@ -2058,6 +2071,8 @@ def _compose_patch_body( res["ssoAppIds"] = sso_app_ids if status is not None: res["status"] = status + if consent_expiration is not None: + res["consentExpiration"] = consent_expiration if test: res["test"] = test return res @@ -2086,6 +2101,7 @@ def _compose_patch_batch_body( sso_app_ids=user.sso_app_ids, status=user.status, test=test, + consent_expiration=user.consent_expiration, ) users_body.append(user_body) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index ed80f0c07..668475687 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -803,6 +803,62 @@ def test_patch_batch(self): json_payload = call_args[1]["json"] self.assertTrue(json_payload["users"][0]["test"]) + def test_patch_batch_with_consent_expiration(self): + # Test batch patch with consent_expiration field + users = [ + UserObj( + login_id="user1", email="user1@test.com", consent_expiration=1735689600 + ), + UserObj( + login_id="user2", display_name="User Two", consent_expiration=1767225600 + ), + UserObj(login_id="user3", phone="+123456789"), # No consent_expiration + ] + + with patch("requests.patch") as mock_patch: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{"patchedUsers": [{"id": "u1"}, {"id": "u2"}, {"id": "u3"}], "failedUsers": []}""" + ) + mock_patch.return_value = network_resp + + resp = self.client.mgmt.user.patch_batch(users) + + self.assertEqual(len(resp["patchedUsers"]), 3) + self.assertEqual(len(resp["failedUsers"]), 0) + + mock_patch.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_batch_path}", + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + params=None, + json={ + "users": [ + { + "loginId": "user1", + "email": "user1@test.com", + "consentExpiration": 1735689600, + }, + { + "loginId": "user2", + "displayName": "User Two", + "consentExpiration": 1767225600, + }, + { + "loginId": "user3", + "phone": "+123456789", + }, + ] + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_delete(self): # Test failed flows with patch("requests.post") as mock_post: