From c81f18a82798d458be982b77064e61c95f63481e Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 16:07:27 +0300 Subject: [PATCH 1/7] feat(roles-permissions): add UpdateWithID/DeleteWithID and role_ids search Co-Authored-By: Claude Sonnet 4.6 --- descope/management/permission.py | 41 ++++++++++ descope/management/role.py | 66 ++++++++++++++- tests/management/test_permission.py | 77 +++++++++++++++++ tests/management/test_role.py | 123 ++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 1 deletion(-) diff --git a/descope/management/permission.py b/descope/management/permission.py index e256d666d..120524d31 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -108,6 +108,29 @@ def update( body={"name": name, "newName": new_name, "description": description}, ) + def update_with_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + ): + """ + Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing permission. Empty fields will override populated fields. Use carefully. + + Args: + id (str): permission ID (e.g. PERM...). + new_name (str): permission updated name. + description (str): Optional description to briefly explain what this permission allows. + + Raise: + AuthException: raised if update operation fails + """ + self._http.post( + MgmtV1.permission_update_path, + body={"id": id, "newName": new_name, "description": description}, + ) + def delete( self, name: str, @@ -126,6 +149,24 @@ def delete( body={"name": name}, ) + def delete_with_id( + self, + id: str, + ): + """ + Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the permission to be deleted (e.g. PERM...). + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post( + MgmtV1.permission_delete_path, + body={"id": id}, + ) + def load_all( self, ) -> dict: diff --git a/descope/management/role.py b/descope/management/role.py index 9e5623822..865c18202 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -154,6 +154,46 @@ def update( }, ) + def update_with_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + permission_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + default: Optional[bool] = None, + private: Optional[bool] = None, + ): + """ + Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing role. Empty fields will override populated fields. Use carefully. + + Args: + id (str): role ID (e.g. ROL...). + new_name (str): role updated name. + description (str): Optional description to briefly explain what this role allows. + permission_names (List[str]): Optional list of names of permissions this role grants. + tenant_id (str): Optional tenant ID to update the role in. + default (bool): Optional marks this role as default role. + private (bool): Optional marks this role as private role. + + Raise: + AuthException: raised if update operation fails + """ + permission_names = [] if permission_names is None else permission_names + self._http.post( + MgmtV1.role_update_path, + body={ + "id": id, + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, + ) + def delete( self, name: str, @@ -173,6 +213,26 @@ def delete( body={"name": name, "tenantId": tenant_id}, ) + def delete_with_id( + self, + id: str, + tenant_id: Optional[str] = None, + ): + """ + Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the role to be deleted (e.g. ROL...). + tenant_id (str): Optional tenant ID the role belongs to. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post( + MgmtV1.role_delete_path, + body={"id": id, "tenantId": tenant_id}, + ) + def load_all( self, ) -> dict: @@ -199,6 +259,7 @@ def search( role_name_like: Optional[str] = None, permission_names: Optional[List[str]] = None, include_project_roles: Optional[bool] = None, + role_ids: Optional[List[str]] = None, ) -> dict: """ Search roles based on the given filters. @@ -208,10 +269,11 @@ def search( role_names (List[str]): Only return matching roles to the given names role_name_like (str): Return roles that contain the given string ignoring case permission_names (List[str]): Only return roles that have the given permissions + role_ids (List[str]): Only return roles matching the given IDs (e.g. ROL...) Return value (dict): Return dict in the format - {"roles": [{"name": , "description": , "permissionNames":[]}] } + {"roles": [{"id": , "name": , "description": , "permissionNames":[]}] } Containing the loaded role information. Raise: @@ -228,6 +290,8 @@ def search( body["permissionNames"] = permission_names if include_project_roles is not None: body["includeProjectRoles"] = include_project_roles + if role_ids is not None: + body["roleIds"] = role_ids response = self._http.post( MgmtV1.role_search_path, diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index 33142c6ae..6a17f335f 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -146,6 +146,83 @@ def test_delete(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_update_with_id(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.permission.update_with_id, + "PERM123", + "new-name", + ) + + # Test success flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.permission.update_with_id("PERM123", "new-name", "new-description") + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_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={ + "id": "PERM123", + "newName": "new-name", + "description": "new-description", + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_with_id(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.permission.delete_with_id, + "PERM123", + ) + + # Test success flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone(client.mgmt.permission.delete_with_id("PERM123")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_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={"id": "PERM123"}, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_create_batch(self): client = DescopeClient( self.dummy_project_id, diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 35215edbd..6d020b512 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -282,6 +282,95 @@ def test_delete(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_update_with_id(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.role.update_with_id, + "ROL123", + "new-name", + ) + + # Test success flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.role.update_with_id( + "ROL123", + "new-name", + "new-description", + ["P1", "P2"], + "t1", + True, + False, + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_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={ + "id": "ROL123", + "newName": "new-name", + "description": "new-description", + "permissionNames": ["P1", "P2"], + "tenantId": "t1", + "default": True, + "private": False, + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete_with_id(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.role.delete_with_id, + "ROL123", + ) + + # Test success flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone(client.mgmt.role.delete_with_id("ROL123", "t1")) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_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={"id": "ROL123", "tenantId": "t1"}, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_delete_batch(self): client = DescopeClient( self.dummy_project_id, @@ -440,6 +529,40 @@ def test_search(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_search_by_role_ids(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("httpx.post") as mock_post: + network_resp = mock.Mock() + network_resp.is_success = True + network_resp.json.return_value = json.loads( + """{"roles": [{"id": "ROL123", "name": "R1"}]}""" + ) + mock_post.return_value = network_resp + resp = client.mgmt.role.search(role_ids=["ROL123"]) + roles = resp["roles"] + self.assertEqual(len(roles), 1) + self.assertEqual(roles[0]["id"], "ROL123") + self.assertEqual(roles[0]["name"], "R1") + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_search_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={"roleIds": ["ROL123"]}, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_create_with_private_true(self): client = DescopeClient( self.dummy_project_id, From 324672a95fb75cc96228ba0e1ea4914b33e79350 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 16:27:33 +0300 Subject: [PATCH 2/7] refactor(mgmt): collapse *WithID methods into update/delete with keyword id arg; add ids to delete_batch Co-Authored-By: Claude Sonnet 4.6 --- descope/management/permission.py | 95 ++++++++----------- descope/management/role.py | 136 +++++++++++----------------- tests/management/test_permission.py | 39 ++++++-- tests/management/test_role.py | 68 ++++++++------ 4 files changed, 164 insertions(+), 174 deletions(-) diff --git a/descope/management/permission.py b/descope/management/permission.py index 120524d31..1470fdcb9 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -54,7 +54,7 @@ def update_batch( Args: permissions (List[dict]): List of permission objects, each with: - - name (str): current permission name. + - name (str): current permission name (or id: permission ID). - newName (str): new permission name. - description (str): Optional new description. @@ -68,103 +68,88 @@ def update_batch( def delete_batch( self, - names: List[str], + names: Optional[List[str]] = None, + *, + ids: Optional[List[str]] = None, ): """ Delete a batch of permissions in a single atomic transaction. IMPORTANT: This action is irreversible. Use carefully. Args: - names (List[str]): List of permission names to delete. + names (List[str]): Optional list of permission names to delete. + ids (List[str]): Optional list of permission IDs to delete (e.g. PERM...). Raise: AuthException: raised if deletion operation fails """ + body = {} + if names: + body["names"] = names + if ids: + body["ids"] = ids self._http.post( MgmtV1.permission_delete_batch_path, - body={"names": names}, + body=body, ) def update( self, - name: str, - new_name: str, + name: Optional[str] = None, + new_name: str = "", description: Optional[str] = None, + *, + id: Optional[str] = None, ): """ - Update an existing permission with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. + Update an existing permission. Identify by name or ID (exactly one required). + IMPORTANT: All parameters are used as overrides to the existing permission. + Empty fields will override populated fields. Use carefully. Args: - name (str): permission name. + name (str): current permission name (mutually exclusive with id). new_name (str): permission updated name. description (str): Optional description to briefly explain what this permission allows. + id (str): permission ID, e.g. PERM... (mutually exclusive with name). Raise: AuthException: raised if update operation fails """ + body: dict = {"newName": new_name, "description": description} + if id is not None: + body["id"] = id + else: + body["name"] = name self._http.post( MgmtV1.permission_update_path, - body={"name": name, "newName": new_name, "description": description}, - ) - - def update_with_id( - self, - id: str, - new_name: str, - description: Optional[str] = None, - ): - """ - Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing permission. Empty fields will override populated fields. Use carefully. - - Args: - id (str): permission ID (e.g. PERM...). - new_name (str): permission updated name. - description (str): Optional description to briefly explain what this permission allows. - - Raise: - AuthException: raised if update operation fails - """ - self._http.post( - MgmtV1.permission_update_path, - body={"id": id, "newName": new_name, "description": description}, + body=body, ) def delete( self, - name: str, - ): - """ - Delete an existing permission. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the permission to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.permission_delete_path, - body={"name": name}, - ) - - def delete_with_id( - self, - id: str, + name: Optional[str] = None, + *, + id: Optional[str] = None, ): """ - Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. + Delete an existing permission. Identify by name or ID (exactly one required). + IMPORTANT: This action is irreversible. Use carefully. Args: - id (str): The ID of the permission to be deleted (e.g. PERM...). + name (str): The name of the permission to be deleted (mutually exclusive with id). + id (str): The ID of the permission to be deleted, e.g. PERM... (mutually exclusive with name). Raise: AuthException: raised if deletion operation fails """ + body: dict = {} + if id is not None: + body["id"] = id + else: + body["name"] = name self._http.post( MgmtV1.permission_delete_path, - body={"id": id}, + body=body, ) def load_all( diff --git a/descope/management/role.py b/descope/management/role.py index 865c18202..f52ecc477 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -77,7 +77,7 @@ def update_batch( Args: roles (List[dict]): List of role objects, each with: - - name (str): current role name. + - name (str): current role name (or id: role ID). - newName (str): new role name. - description (str): Optional new description. - permissionNames (List[str]): Optional list of permission names. @@ -95,142 +95,112 @@ def update_batch( def delete_batch( self, - roles: List[dict], + role_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + *, + role_ids: Optional[List[str]] = None, ): """ Delete a batch of roles in a single atomic transaction. IMPORTANT: This action is irreversible. Use carefully. Args: - roles (List[dict]): List of role objects to delete, each with: - - name (str): role name. - - tenantId (str): Optional tenant ID. + role_names (List[str]): Optional list of role names to delete. + tenant_id (str): Optional tenant ID the roles belong to. + role_ids (List[str]): Optional list of role IDs to delete (e.g. ROL...). Raise: AuthException: raised if deletion operation fails """ + body: dict = {} + if role_names: + body["roleNames"] = role_names + if tenant_id is not None: + body["tenantId"] = tenant_id + if role_ids: + body["roleIds"] = role_ids self._http.post( MgmtV1.role_delete_batch_path, - body={"roles": roles}, + body=body, ) def update( self, - name: str, - new_name: str, - description: Optional[str] = None, - permission_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, - default: Optional[bool] = None, - private: Optional[bool] = None, - ): - """ - Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. - - Args: - name (str): role name. - new_name (str): role updated name. - description (str): Optional description to briefly explain what this role allows. - permission_names (List[str]): Optional list of names of permissions this role grants. - tenant_id (str): Optional tenant ID to update the role in. - default (bool): Optional marks this role as default role. - private (bool): Optional marks this role as private role. - - Raise: - AuthException: raised if update operation fails - """ - permission_names = [] if permission_names is None else permission_names - self._http.post( - MgmtV1.role_update_path, - body={ - "name": name, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, - ) - - def update_with_id( - self, - id: str, - new_name: str, + name: Optional[str] = None, + new_name: str = "", description: Optional[str] = None, permission_names: Optional[List[str]] = None, tenant_id: Optional[str] = None, default: Optional[bool] = None, private: Optional[bool] = None, + *, + id: Optional[str] = None, ): """ - Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides - to the existing role. Empty fields will override populated fields. Use carefully. + Update an existing role. Identify by name or ID (exactly one required). + IMPORTANT: All parameters are used as overrides to the existing role. + Empty fields will override populated fields. Use carefully. Args: - id (str): role ID (e.g. ROL...). + name (str): current role name (mutually exclusive with id). new_name (str): role updated name. description (str): Optional description to briefly explain what this role allows. permission_names (List[str]): Optional list of names of permissions this role grants. tenant_id (str): Optional tenant ID to update the role in. default (bool): Optional marks this role as default role. private (bool): Optional marks this role as private role. + id (str): role ID, e.g. ROL... (mutually exclusive with name). Raise: AuthException: raised if update operation fails """ permission_names = [] if permission_names is None else permission_names + body: dict = { + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + } + if id is not None: + body["id"] = id + else: + body["name"] = name self._http.post( MgmtV1.role_update_path, - body={ - "id": id, - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - }, + body=body, ) def delete( self, - name: str, + name: Optional[str] = None, tenant_id: Optional[str] = None, + *, + id: Optional[str] = None, ): """ - Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. - - Args: - name (str): The name of the role to be deleted. - - Raise: - AuthException: raised if creation operation fails - """ - self._http.post( - MgmtV1.role_delete_path, - body={"name": name, "tenantId": tenant_id}, - ) - - def delete_with_id( - self, - id: str, - tenant_id: Optional[str] = None, - ): - """ - Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. + Delete an existing role. Identify by name or ID (exactly one required). + IMPORTANT: This action is irreversible. Use carefully. Args: - id (str): The ID of the role to be deleted (e.g. ROL...). + name (str): The name of the role to be deleted (mutually exclusive with id). tenant_id (str): Optional tenant ID the role belongs to. + id (str): The ID of the role to be deleted, e.g. ROL... (mutually exclusive with name). Raise: AuthException: raised if deletion operation fails """ + body: dict = {} + if id is not None: + body["id"] = id + else: + body["name"] = name + if tenant_id is not None: + body["tenantId"] = tenant_id self._http.post( MgmtV1.role_delete_path, - body={"id": id, "tenantId": tenant_id}, + body=body, ) def load_all( diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index 6a17f335f..1768969f0 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -159,16 +159,16 @@ def test_update_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.permission.update_with_id, - "PERM123", - "new-name", + client.mgmt.permission.update, + id="PERM123", + new_name="new-name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( - client.mgmt.permission.update_with_id("PERM123", "new-name", "new-description") + client.mgmt.permission.update(new_name="new-name", description="new-description", id="PERM123") ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", @@ -201,14 +201,14 @@ def test_delete_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.permission.delete_with_id, - "PERM123", + client.mgmt.permission.delete, + id="PERM123", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete_with_id("PERM123")) + self.assertIsNone(client.mgmt.permission.delete(id="PERM123")) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", headers={ @@ -352,6 +352,31 @@ def test_delete_batch(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + def test_delete_batch_by_ids(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone(client.mgmt.permission.delete_batch(ids=["PERM1", "PERM2"])) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_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={"ids": ["PERM1", "PERM2"]}, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_load_all(self): client = DescopeClient( self.dummy_project_id, diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 6d020b512..e37caa33d 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -276,7 +276,7 @@ def test_delete(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - json={"name": "name", "tenantId": None}, + json={"name": "name"}, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, @@ -295,23 +295,23 @@ def test_update_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.role.update_with_id, - "ROL123", - "new-name", + client.mgmt.role.update, + id="ROL123", + new_name="new-name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( - client.mgmt.role.update_with_id( - "ROL123", - "new-name", - "new-description", - ["P1", "P2"], - "t1", - True, - False, + client.mgmt.role.update( + new_name="new-name", + description="new-description", + permission_names=["P1", "P2"], + tenant_id="t1", + default=True, + private=False, + id="ROL123", ) ) mock_post.assert_called_with( @@ -349,14 +349,14 @@ def test_delete_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.role.delete_with_id, - "ROL123", + client.mgmt.role.delete, + id="ROL123", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete_with_id("ROL123", "t1")) + self.assertIsNone(client.mgmt.role.delete(tenant_id="t1", id="ROL123")) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", headers={ @@ -379,25 +379,20 @@ def test_delete_batch(self): self.dummy_management_key, ) - # Test failed flow + # Test failed flow (by names) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, client.mgmt.role.delete_batch, - [{"name": "R1"}], + ["R1"], ) - # Test success flow + # Test success flow — by names with tenantId with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( - client.mgmt.role.delete_batch( - [ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2"}, - ] - ) + client.mgmt.role.delete_batch(["R1", "R2"], "t1") ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", @@ -407,12 +402,27 @@ def test_delete_batch(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - json={ - "roles": [ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2"}, - ] + json={"roleNames": ["R1", "R2"], "tenantId": "t1"}, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + # Test success flow — by IDs + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.role.delete_batch(role_ids=["ROL1", "ROL2"]) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_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={"roleIds": ["ROL1", "ROL2"]}, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, From f41692838e0a839d5c3560cf5527add4d8236d15 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 16:37:07 +0300 Subject: [PATCH 3/7] fix(mgmt): restore backwards-compatible delete_batch signature; add id/role_ids kwargs only Co-Authored-By: Claude Sonnet 4.6 --- descope/management/permission.py | 4 ++-- descope/management/role.py | 23 ++++++++++++----------- tests/management/test_role.py | 15 +++++++++------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/descope/management/permission.py b/descope/management/permission.py index 1470fdcb9..d4774c38f 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -83,8 +83,8 @@ def delete_batch( Raise: AuthException: raised if deletion operation fails """ - body = {} - if names: + body: dict = {} + if names is not None: body["names"] = names if ids: body["ids"] = ids diff --git a/descope/management/role.py b/descope/management/role.py index f52ecc477..9ca921846 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -95,8 +95,7 @@ def update_batch( def delete_batch( self, - role_names: Optional[List[str]] = None, - tenant_id: Optional[str] = None, + roles: Optional[List[dict]] = None, *, role_ids: Optional[List[str]] = None, ): @@ -105,18 +104,22 @@ def delete_batch( IMPORTANT: This action is irreversible. Use carefully. Args: - role_names (List[str]): Optional list of role names to delete. - tenant_id (str): Optional tenant ID the roles belong to. + roles (List[dict]): List of role objects to delete, each with: + - name (str): role name. + - tenantId (str): Optional tenant ID. role_ids (List[str]): Optional list of role IDs to delete (e.g. ROL...). Raise: AuthException: raised if deletion operation fails """ body: dict = {} - if role_names: - body["roleNames"] = role_names - if tenant_id is not None: - body["tenantId"] = tenant_id + if roles: + names = [r["name"] for r in roles if r.get("name")] + if names: + body["roleNames"] = names + tenant_id = next((r.get("tenantId") for r in roles if r.get("tenantId")), None) + if tenant_id: + body["tenantId"] = tenant_id if role_ids: body["roleIds"] = role_ids self._http.post( @@ -191,13 +194,11 @@ def delete( Raise: AuthException: raised if deletion operation fails """ - body: dict = {} + body: dict = {"tenantId": tenant_id} if id is not None: body["id"] = id else: body["name"] = name - if tenant_id is not None: - body["tenantId"] = tenant_id self._http.post( MgmtV1.role_delete_path, body=body, diff --git a/tests/management/test_role.py b/tests/management/test_role.py index e37caa33d..5d6e7984e 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -276,7 +276,7 @@ def test_delete(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - json={"name": "name"}, + json={"name": "name", "tenantId": None}, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, @@ -379,20 +379,23 @@ def test_delete_batch(self): self.dummy_management_key, ) - # Test failed flow (by names) + # Test failed flow (by names, original call style) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, client.mgmt.role.delete_batch, - ["R1"], + [{"name": "R1"}], ) - # Test success flow — by names with tenantId + # Test success flow — by names with tenantId (original call style) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( - client.mgmt.role.delete_batch(["R1", "R2"], "t1") + client.mgmt.role.delete_batch([ + {"name": "R1", "tenantId": "t1"}, + {"name": "R2"}, + ]) ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", @@ -408,7 +411,7 @@ def test_delete_batch(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - # Test success flow — by IDs + # Test success flow — by IDs (new kwarg) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( From efe2ba4d1deea48d327ad2a99525b6fae7b27b66 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 19:21:21 +0300 Subject: [PATCH 4/7] refactor(mgmt): replace id-kwarg approach with explicit _by_id methods; zero breaking changes Co-Authored-By: Claude Sonnet 4.6 --- descope/management/permission.py | 114 +++++++++++++-------- descope/management/role.py | 152 ++++++++++++++++++---------- tests/management/test_permission.py | 58 ++++++----- tests/management/test_role.py | 123 ++++++++++++---------- 4 files changed, 280 insertions(+), 167 deletions(-) diff --git a/descope/management/permission.py b/descope/management/permission.py index d4774c38f..6c21f9fad 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -54,7 +54,7 @@ def update_batch( Args: permissions (List[dict]): List of permission objects, each with: - - name (str): current permission name (or id: permission ID). + - name (str): current permission name. - newName (str): new permission name. - description (str): Optional new description. @@ -68,88 +68,122 @@ def update_batch( def delete_batch( self, - names: Optional[List[str]] = None, - *, - ids: Optional[List[str]] = None, + names: List[str], ): """ Delete a batch of permissions in a single atomic transaction. IMPORTANT: This action is irreversible. Use carefully. Args: - names (List[str]): Optional list of permission names to delete. - ids (List[str]): Optional list of permission IDs to delete (e.g. PERM...). + names (List[str]): List of permission names to delete. Raise: AuthException: raised if deletion operation fails """ - body: dict = {} - if names is not None: - body["names"] = names - if ids: - body["ids"] = ids self._http.post( MgmtV1.permission_delete_batch_path, - body=body, + body={"names": names}, + ) + + def delete_batch_by_ids( + self, + ids: List[str], + ): + """ + Delete a batch of permissions by their IDs in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): List of permission IDs to delete (e.g. PERM...). + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post( + MgmtV1.permission_delete_batch_path, + body={"ids": ids}, ) def update( self, - name: Optional[str] = None, - new_name: str = "", + name: str, + new_name: str, description: Optional[str] = None, - *, - id: Optional[str] = None, ): """ - Update an existing permission. Identify by name or ID (exactly one required). - IMPORTANT: All parameters are used as overrides to the existing permission. - Empty fields will override populated fields. Use carefully. + Update an existing permission with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing permission. Empty fields will override populated fields. Use carefully. Args: - name (str): current permission name (mutually exclusive with id). + name (str): permission name. new_name (str): permission updated name. description (str): Optional description to briefly explain what this permission allows. - id (str): permission ID, e.g. PERM... (mutually exclusive with name). Raise: AuthException: raised if update operation fails """ - body: dict = {"newName": new_name, "description": description} - if id is not None: - body["id"] = id - else: - body["name"] = name self._http.post( MgmtV1.permission_update_path, - body=body, + body={"name": name, "newName": new_name, "description": description}, + ) + + def update_by_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + ): + """ + Update an existing permission identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing permission. Empty fields will override populated fields. Use carefully. + + Args: + id (str): permission ID (e.g. PERM...). + new_name (str): permission updated name. + description (str): Optional description to briefly explain what this permission allows. + + Raise: + AuthException: raised if update operation fails + """ + self._http.post( + MgmtV1.permission_update_path, + body={"id": id, "newName": new_name, "description": description}, ) def delete( self, - name: Optional[str] = None, - *, - id: Optional[str] = None, + name: str, ): """ - Delete an existing permission. Identify by name or ID (exactly one required). - IMPORTANT: This action is irreversible. Use carefully. + Delete an existing permission. IMPORTANT: This action is irreversible. Use carefully. + + Args: + name (str): The name of the permission to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + self._http.post( + MgmtV1.permission_delete_path, + body={"name": name}, + ) + + def delete_by_id( + self, + id: str, + ): + """ + Delete an existing permission by its ID. IMPORTANT: This action is irreversible. Use carefully. Args: - name (str): The name of the permission to be deleted (mutually exclusive with id). - id (str): The ID of the permission to be deleted, e.g. PERM... (mutually exclusive with name). + id (str): The ID of the permission to be deleted (e.g. PERM...). Raise: AuthException: raised if deletion operation fails """ - body: dict = {} - if id is not None: - body["id"] = id - else: - body["name"] = name self._http.post( MgmtV1.permission_delete_path, - body=body, + body={"id": id}, ) def load_all( diff --git a/descope/management/role.py b/descope/management/role.py index 9ca921846..60d68e9f8 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -77,7 +77,7 @@ def update_batch( Args: roles (List[dict]): List of role objects, each with: - - name (str): current role name (or id: role ID). + - name (str): current role name. - newName (str): new role name. - description (str): Optional new description. - permissionNames (List[str]): Optional list of permission names. @@ -95,9 +95,7 @@ def update_batch( def delete_batch( self, - roles: Optional[List[dict]] = None, - *, - role_ids: Optional[List[str]] = None, + roles: List[dict], ): """ Delete a batch of roles in a single atomic transaction. @@ -107,101 +105,153 @@ def delete_batch( roles (List[dict]): List of role objects to delete, each with: - name (str): role name. - tenantId (str): Optional tenant ID. - role_ids (List[str]): Optional list of role IDs to delete (e.g. ROL...). Raise: AuthException: raised if deletion operation fails """ - body: dict = {} - if roles: - names = [r["name"] for r in roles if r.get("name")] - if names: - body["roleNames"] = names - tenant_id = next((r.get("tenantId") for r in roles if r.get("tenantId")), None) - if tenant_id: - body["tenantId"] = tenant_id - if role_ids: - body["roleIds"] = role_ids self._http.post( MgmtV1.role_delete_batch_path, - body=body, + body={"roles": roles}, + ) + + def delete_batch_by_ids( + self, + role_ids: List[str], + tenant_id: Optional[str] = None, + ): + """ + Delete a batch of roles by their IDs in a single atomic transaction. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + role_ids (List[str]): List of role IDs to delete (e.g. ROL...). + tenant_id (str): Optional tenant ID the roles belong to. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post( + MgmtV1.role_delete_batch_path, + body={"roleIds": role_ids, "tenantId": tenant_id}, ) def update( self, - name: Optional[str] = None, - new_name: str = "", + name: str, + new_name: str, description: Optional[str] = None, permission_names: Optional[List[str]] = None, tenant_id: Optional[str] = None, default: Optional[bool] = None, private: Optional[bool] = None, - *, - id: Optional[str] = None, ): """ - Update an existing role. Identify by name or ID (exactly one required). - IMPORTANT: All parameters are used as overrides to the existing role. - Empty fields will override populated fields. Use carefully. + Update an existing role with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing role. Empty fields will override populated fields. Use carefully. Args: - name (str): current role name (mutually exclusive with id). + name (str): role name. new_name (str): role updated name. description (str): Optional description to briefly explain what this role allows. permission_names (List[str]): Optional list of names of permissions this role grants. tenant_id (str): Optional tenant ID to update the role in. default (bool): Optional marks this role as default role. private (bool): Optional marks this role as private role. - id (str): role ID, e.g. ROL... (mutually exclusive with name). Raise: AuthException: raised if update operation fails """ permission_names = [] if permission_names is None else permission_names - body: dict = { - "newName": new_name, - "description": description, - "permissionNames": permission_names, - "tenantId": tenant_id, - "default": default, - "private": private, - } - if id is not None: - body["id"] = id - else: - body["name"] = name self._http.post( MgmtV1.role_update_path, - body=body, + body={ + "name": name, + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, + ) + + def update_by_id( + self, + id: str, + new_name: str, + description: Optional[str] = None, + permission_names: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + default: Optional[bool] = None, + private: Optional[bool] = None, + ): + """ + Update an existing role identified by its ID. IMPORTANT: All parameters are used as overrides + to the existing role. Empty fields will override populated fields. Use carefully. + + Args: + id (str): role ID (e.g. ROL...). + new_name (str): role updated name. + description (str): Optional description to briefly explain what this role allows. + permission_names (List[str]): Optional list of names of permissions this role grants. + tenant_id (str): Optional tenant ID to update the role in. + default (bool): Optional marks this role as default role. + private (bool): Optional marks this role as private role. + + Raise: + AuthException: raised if update operation fails + """ + permission_names = [] if permission_names is None else permission_names + self._http.post( + MgmtV1.role_update_path, + body={ + "id": id, + "newName": new_name, + "description": description, + "permissionNames": permission_names, + "tenantId": tenant_id, + "default": default, + "private": private, + }, ) def delete( self, - name: Optional[str] = None, + name: str, tenant_id: Optional[str] = None, - *, - id: Optional[str] = None, ): """ - Delete an existing role. Identify by name or ID (exactly one required). - IMPORTANT: This action is irreversible. Use carefully. + Delete an existing role. IMPORTANT: This action is irreversible. Use carefully. + + Args: + name (str): The name of the role to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + self._http.post( + MgmtV1.role_delete_path, + body={"name": name, "tenantId": tenant_id}, + ) + + def delete_by_id( + self, + id: str, + tenant_id: Optional[str] = None, + ): + """ + Delete an existing role by its ID. IMPORTANT: This action is irreversible. Use carefully. Args: - name (str): The name of the role to be deleted (mutually exclusive with id). + id (str): The ID of the role to be deleted (e.g. ROL...). tenant_id (str): Optional tenant ID the role belongs to. - id (str): The ID of the role to be deleted, e.g. ROL... (mutually exclusive with name). Raise: AuthException: raised if deletion operation fails """ - body: dict = {"tenantId": tenant_id} - if id is not None: - body["id"] = id - else: - body["name"] = name self._http.post( MgmtV1.role_delete_path, - body=body, + body={"id": id, "tenantId": tenant_id}, ) def load_all( diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index 1768969f0..e1c704730 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -109,7 +109,7 @@ def test_update(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): + def test_update_by_id(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -117,21 +117,24 @@ def test_delete(self): self.dummy_management_key, ) - # Test failed flows + # Test failed flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.permission.delete, - "name", + client.mgmt.permission.update_by_id, + "PERM123", + "new-name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete("name")) + self.assertIsNone( + client.mgmt.permission.update_by_id("PERM123", "new-name", "new-description") + ) mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", headers={ **common.default_headers, "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", @@ -139,14 +142,16 @@ def test_delete(self): }, params=None, json={ - "name": "name", + "id": "PERM123", + "newName": "new-name", + "description": "new-description", }, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_with_id(self): + def test_delete(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -154,24 +159,21 @@ def test_update_with_id(self): self.dummy_management_key, ) - # Test failed flow + # Test failed flows with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.permission.update, - id="PERM123", - new_name="new-name", + client.mgmt.permission.delete, + "name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.permission.update(new_name="new-name", description="new-description", id="PERM123") - ) + self.assertIsNone(client.mgmt.permission.delete("name")) mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_path}", + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", headers={ **common.default_headers, "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", @@ -179,16 +181,14 @@ def test_update_with_id(self): }, params=None, json={ - "id": "PERM123", - "newName": "new-name", - "description": "new-description", + "name": "name", }, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_with_id(self): + def test_delete_by_id(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -201,14 +201,14 @@ def test_delete_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.permission.delete, - id="PERM123", + client.mgmt.permission.delete_by_id, + "PERM123", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete(id="PERM123")) + self.assertIsNone(client.mgmt.permission.delete_by_id("PERM123")) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_path}", headers={ @@ -360,9 +360,19 @@ def test_delete_batch_by_ids(self): self.dummy_management_key, ) + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.permission.delete_batch_by_ids, + ["PERM1"], + ) + + # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.permission.delete_batch(ids=["PERM1", "PERM2"])) + self.assertIsNone(client.mgmt.permission.delete_batch_by_ids(["PERM1", "PERM2"])) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_delete_batch_path}", headers={ diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 5d6e7984e..b1e9caf9b 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -247,7 +247,7 @@ def test_update(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete(self): + def test_update_by_id(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -255,34 +255,53 @@ def test_delete(self): self.dummy_management_key, ) - # Test failed flows + # Test failed flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.role.delete, - "name", + client.mgmt.role.update_by_id, + "ROL123", + "new-name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete("name")) + self.assertIsNone( + client.mgmt.role.update_by_id( + "ROL123", + "new-name", + "new-description", + ["P1", "P2"], + "t1", + True, + False, + ) + ) mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_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={"name": "name", "tenantId": None}, + json={ + "id": "ROL123", + "newName": "new-name", + "description": "new-description", + "permissionNames": ["P1", "P2"], + "tenantId": "t1", + "default": True, + "private": False, + }, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_update_with_id(self): + def test_delete(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -290,53 +309,34 @@ def test_update_with_id(self): self.dummy_management_key, ) - # Test failed flow + # Test failed flows with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.role.update, - id="ROL123", - new_name="new-name", + client.mgmt.role.delete, + "name", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.update( - new_name="new-name", - description="new-description", - permission_names=["P1", "P2"], - tenant_id="t1", - default=True, - private=False, - id="ROL123", - ) - ) + self.assertIsNone(client.mgmt.role.delete("name")) mock_post.assert_called_with( - f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_path}", + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_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={ - "id": "ROL123", - "newName": "new-name", - "description": "new-description", - "permissionNames": ["P1", "P2"], - "tenantId": "t1", - "default": True, - "private": False, - }, + json={"name": "name", "tenantId": None}, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) - def test_delete_with_id(self): + def test_delete_by_id(self): client = DescopeClient( self.dummy_project_id, self.public_key_dict, @@ -349,14 +349,14 @@ def test_delete_with_id(self): mock_post.return_value.is_success = False self.assertRaises( AuthException, - client.mgmt.role.delete, - id="ROL123", + client.mgmt.role.delete_by_id, + "ROL123", ) # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete(tenant_id="t1", id="ROL123")) + self.assertIsNone(client.mgmt.role.delete_by_id("ROL123", "t1")) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_path}", headers={ @@ -379,7 +379,7 @@ def test_delete_batch(self): self.dummy_management_key, ) - # Test failed flow (by names, original call style) + # Test failed flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = False self.assertRaises( @@ -388,14 +388,16 @@ def test_delete_batch(self): [{"name": "R1"}], ) - # Test success flow — by names with tenantId (original call style) + # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( - client.mgmt.role.delete_batch([ - {"name": "R1", "tenantId": "t1"}, - {"name": "R2"}, - ]) + client.mgmt.role.delete_batch( + [ + {"name": "R1", "tenantId": "t1"}, + {"name": "R2"}, + ] + ) ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", @@ -405,18 +407,38 @@ def test_delete_batch(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - json={"roleNames": ["R1", "R2"], "tenantId": "t1"}, + json={ + "roles": [ + {"name": "R1", "tenantId": "t1"}, + {"name": "R2"}, + ] + }, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, ) - # Test success flow — by IDs (new kwarg) + def test_delete_batch_by_ids(self): + client = DescopeClient( + self.dummy_project_id, + self.public_key_dict, + False, + self.dummy_management_key, + ) + + # Test failed flow + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = False + self.assertRaises( + AuthException, + client.mgmt.role.delete_batch_by_ids, + ["ROL1"], + ) + + # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone( - client.mgmt.role.delete_batch(role_ids=["ROL1", "ROL2"]) - ) + self.assertIsNone(client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1")) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", headers={ @@ -425,7 +447,7 @@ def test_delete_batch(self): "x-descope-project-id": self.dummy_project_id, }, params=None, - json={"roleIds": ["ROL1", "ROL2"]}, + json={"roleIds": ["ROL1", "ROL2"], "tenantId": "t1"}, follow_redirects=False, verify=SSLMatcher(), timeout=DEFAULT_TIMEOUT_SECONDS, @@ -584,7 +606,6 @@ def test_create_with_private_true(self): self.dummy_management_key, ) - # Test private=True with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -621,7 +642,6 @@ def test_update_with_private_true(self): self.dummy_management_key, ) - # Test private=True with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -665,7 +685,6 @@ def test_create_without_private_parameter(self): self.dummy_management_key, ) - # Test without private parameter (should be None) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone(client.mgmt.role.create("SimpleRole", "Simple role")) From ddf62d52d863076dfc74027456b107b00f2f21dd Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 20:07:25 +0300 Subject: [PATCH 5/7] (test): formatting and bringing back comments from main branch that are not related --- tests/management/test_role.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/management/test_role.py b/tests/management/test_role.py index b1e9caf9b..924d0feb1 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -438,7 +438,9 @@ def test_delete_batch_by_ids(self): # Test success flow with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True - self.assertIsNone(client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1")) + self.assertIsNone( + client.mgmt.role.delete_batch_by_ids(["ROL1", "ROL2"], "t1") + ) mock_post.assert_called_with( f"{common.DEFAULT_BASE_URL}{MgmtV1.role_delete_batch_path}", headers={ @@ -470,16 +472,14 @@ def test_load_all(self): with patch("httpx.get") as mock_get: network_resp = mock.Mock() network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ + network_resp.json.return_value = json.loads(""" { "roles": [ {"name": "R1", "permissionNames": ["P1", "P2"]}, {"name": "R2"} ] } - """ - ) + """) mock_get.return_value = network_resp resp = client.mgmt.role.load_all() roles = resp["roles"] @@ -525,16 +525,14 @@ def test_search(self): with patch("httpx.post") as mock_post: network_resp = mock.Mock() network_resp.is_success = True - network_resp.json.return_value = json.loads( - """ + network_resp.json.return_value = json.loads(""" { "roles": [ {"name": "R1", "permissionNames": ["P1", "P2"]}, {"name": "R2"} ] } - """ - ) + """) mock_post.return_value = network_resp resp = client.mgmt.role.search(["t"], ["r"], "x", ["p1", "p2"]) roles = resp["roles"] @@ -606,6 +604,7 @@ def test_create_with_private_true(self): self.dummy_management_key, ) + # Test private=True with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -642,6 +641,7 @@ def test_update_with_private_true(self): self.dummy_management_key, ) + # Test private=True with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -685,6 +685,7 @@ def test_create_without_private_parameter(self): self.dummy_management_key, ) + # Test without private parameter (should be None) with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone(client.mgmt.role.create("SimpleRole", "Simple role")) From 8d9ddbc4b52e2c8a98b962a1f7bad06f2f7995fb Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 20:08:28 +0300 Subject: [PATCH 6/7] docs(mgmt): note id alternative in update_batch docstrings Co-Authored-By: Claude Sonnet 4.6 --- descope/management/permission.py | 2 +- descope/management/role.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/descope/management/permission.py b/descope/management/permission.py index 6c21f9fad..7ba253d36 100644 --- a/descope/management/permission.py +++ b/descope/management/permission.py @@ -54,7 +54,7 @@ def update_batch( Args: permissions (List[dict]): List of permission objects, each with: - - name (str): current permission name. + - name (str): current permission name (or id (str): permission ID, e.g. PERM...). - newName (str): new permission name. - description (str): Optional new description. diff --git a/descope/management/role.py b/descope/management/role.py index 60d68e9f8..4d18f33f7 100644 --- a/descope/management/role.py +++ b/descope/management/role.py @@ -77,7 +77,7 @@ def update_batch( Args: roles (List[dict]): List of role objects, each with: - - name (str): current role name. + - name (str): current role name (or id (str): role ID, e.g. ROL...). - newName (str): new role name. - description (str): Optional new description. - permissionNames (List[str]): Optional list of permission names. From cb5ec0aab1a8ed30e04f32bf51f54880b3dc1323 Mon Sep 17 00:00:00 2001 From: Yosi Haran Date: Sun, 3 May 2026 20:09:32 +0300 Subject: [PATCH 7/7] test(mgmt): cover update_batch by id in permission and role tests Co-Authored-By: Claude Sonnet 4.6 --- tests/management/test_permission.py | 32 ++++++++++++++++++++++++++++- tests/management/test_role.py | 32 ++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/management/test_permission.py b/tests/management/test_permission.py index e1c704730..5c7dbd686 100644 --- a/tests/management/test_permission.py +++ b/tests/management/test_permission.py @@ -287,7 +287,7 @@ def test_update_batch(self): [{"name": "P1", "newName": "P1-new"}], ) - # Test success flow + # Test success flow — by name with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -317,6 +317,36 @@ def test_update_batch(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + # Test success flow — by id + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.permission.update_batch( + [ + {"id": "PERM1", "newName": "P1-new", "description": "d1"}, + {"id": "PERM2", "newName": "P2-new"}, + ] + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.permission_update_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={ + "permissions": [ + {"id": "PERM1", "newName": "P1-new", "description": "d1"}, + {"id": "PERM2", "newName": "P2-new"}, + ] + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_delete_batch(self): client = DescopeClient( self.dummy_project_id, diff --git a/tests/management/test_role.py b/tests/management/test_role.py index 924d0feb1..4158ac17b 100644 --- a/tests/management/test_role.py +++ b/tests/management/test_role.py @@ -147,7 +147,7 @@ def test_update_batch(self): [{"name": "R1", "newName": "R1-new"}], ) - # Test success flow + # Test success flow — by name with patch("httpx.post") as mock_post: mock_post.return_value.is_success = True self.assertIsNone( @@ -193,6 +193,36 @@ def test_update_batch(self): timeout=DEFAULT_TIMEOUT_SECONDS, ) + # Test success flow — by id + with patch("httpx.post") as mock_post: + mock_post.return_value.is_success = True + self.assertIsNone( + client.mgmt.role.update_batch( + [ + {"id": "ROL1", "newName": "R1-new", "description": "d1"}, + {"id": "ROL2", "newName": "R2-new"}, + ] + ) + ) + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.role_update_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={ + "roles": [ + {"id": "ROL1", "newName": "R1-new", "description": "d1"}, + {"id": "ROL2", "newName": "R2-new"}, + ] + }, + follow_redirects=False, + verify=SSLMatcher(), + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + def test_update(self): client = DescopeClient( self.dummy_project_id,