diff --git a/README.md b/README.md index 052872960..be29e1682 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ These sections show how to use the SDK to perform permission and user management 14. [Manage Project](#manage-project) 15. [Manage SSO Applications](#manage-sso-applications) 16. [Manage Outbound Applications](#manage-outbound-applications) +17. [Manage Descopers](#manage-descopers) If you wish to run any of our code samples and play with them, check out our [Code Examples](#code-examples) section. @@ -1512,6 +1513,74 @@ latest_tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_te ) ``` +### Manage Descopers + +You can create, update, delete, load or list Descopers (users who have access to the Descope console): + +```python +from descope import ( + DescoperAttributes, + DescoperCreate, + DescoperProjectRole, + DescoperRBAC, + DescoperRole, +) + +# Create a new Descoper +resp = descope_client.mgmt.descoper.create( + descopers=[ + DescoperCreate( + login_id="user@example.com", + attributes=DescoperAttributes( + display_name="John Doe", + email="user@example.com", + phone="+1234567890", + ), + send_invite=True, # Send an invitation email + rbac=DescoperRBAC( + is_company_admin=False, + projects=[ + DescoperProjectRole( + project_ids=["project-id-1"], + role=DescoperRole.ADMIN, + ) + ], + ), + ) + ] +) +descopers = resp["descopers"] +total = resp["total"] + +# Load a Descoper by ID +resp = descope_client.mgmt.descoper.load("descoper-id") +descoper = resp["descoper"] + +# Update a Descoper's attributes and/or RBAC +# Note: All fields that are set will override existing values +resp = descope_client.mgmt.descoper.update( + id="descoper-id", + attributes=DescoperAttributes( + display_name="Updated Name", + ), + rbac=DescoperRBAC( + is_company_admin=True, + ), +) +updated_descoper = resp["descoper"] + +# List all Descopers +resp = descope_client.mgmt.descoper.list() +descopers = resp["descopers"] +total = resp["total"] +for descoper in descopers: + # Do something + +# Delete a Descoper +# Descoper deletion cannot be undone. Use carefully. +descope_client.mgmt.descoper.delete("descoper-id") +``` + ### Utils for your end to end (e2e) tests and integration tests To ease your e2e tests, we exposed dedicated management methods, diff --git a/descope/__init__.py b/descope/__init__.py index 7e71f2f6e..a39429e23 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -19,6 +19,12 @@ ) from descope.management.common import ( AssociatedTenant, + DescoperAttributes, + DescoperCreate, + DescoperProjectRole, + DescoperRBAC, + DescoperRole, + DescoperTagRole, SAMLIDPAttributeMappingInfo, SAMLIDPGroupsMappingInfo, SAMLIDPRoleGroupMappingInfo, diff --git a/descope/http_client.py b/descope/http_client.py index 540812c75..1a4d745dd 100644 --- a/descope/http_client.py +++ b/descope/http_client.py @@ -113,6 +113,26 @@ def post( self._raise_from_response(response) return response + def put( + self, + uri: str, + *, + body: Optional[Union[dict, list[dict], list[str]]] = None, + params=None, + pswd: Optional[str] = None, + ) -> requests.Response: + response = requests.put( + 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 patch( self, uri: str, diff --git a/descope/management/common.py b/descope/management/common.py index 886bf7c90..2480c224c 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -258,6 +258,13 @@ class MgmtV1: project_import = "/v1/mgmt/project/import" project_list_projects = "/v1/mgmt/projects/list" + # Descoper + descoper_create_path = "/v1/mgmt/descoper" + descoper_update_path = "/v1/mgmt/descoper" + descoper_load_path = "/v1/mgmt/descoper" + descoper_delete_path = "/v1/mgmt/descoper" + descoper_list_path = "/v1/mgmt/descoper/list" + class MgmtSignUpOptions: def __init__( @@ -468,3 +475,128 @@ def sort_to_dict(sort: List[Sort]) -> list: } ) return sort_list + + +class DescoperRole(Enum): + """Represents a Descoper role.""" + + ADMIN = "admin" + DEVELOPER = "developer" + SUPPORT = "support" + AUDITOR = "auditor" + + +class DescoperAttributes: + """ + Represents Descoper attributes, such as name and email/phone. + """ + + def __init__( + self, + display_name: Optional[str] = None, + email: Optional[str] = None, + phone: Optional[str] = None, + ): + self.display_name = display_name + self.email = email + self.phone = phone + + def to_dict(self) -> dict: + return { + "displayName": self.display_name, + "email": self.email, + "phone": self.phone, + } + + +class DescoperTagRole: + """ + Represents a Descoper tags to role mapping. + """ + + def __init__( + self, + tags: Optional[List[str]] = None, + role: Optional[DescoperRole] = None, + ): + self.tags = tags if tags is not None else [] + self.role = role + + def to_dict(self) -> dict: + return { + "tags": self.tags, + "role": self.role.value if self.role else None, + } + + +class DescoperProjectRole: + """ + Represents a Descoper projects to role mapping. + """ + + def __init__( + self, + project_ids: Optional[List[str]] = None, + role: Optional[DescoperRole] = None, + ): + self.project_ids = project_ids if project_ids is not None else [] + self.role = role + + def to_dict(self) -> dict: + return { + "projectIds": self.project_ids, + "role": self.role.value if self.role else None, + } + + +class DescoperRBAC: + """ + Represents Descoper RBAC configuration. + """ + + def __init__( + self, + is_company_admin: bool = False, + tags: Optional[List[DescoperTagRole]] = None, + projects: Optional[List[DescoperProjectRole]] = None, + ): + self.is_company_admin = is_company_admin + self.tags = tags if tags is not None else [] + self.projects = projects if projects is not None else [] + + def to_dict(self) -> dict: + return { + "isCompanyAdmin": self.is_company_admin, + "tags": [t.to_dict() for t in self.tags], + "projects": [p.to_dict() for p in self.projects], + } + + +class DescoperCreate: + """ + Represents a Descoper to be created. + """ + + def __init__( + self, + login_id: str, + attributes: Optional[DescoperAttributes] = None, + send_invite: bool = False, + rbac: Optional[DescoperRBAC] = None, + ): + self.login_id = login_id + self.attributes = attributes + self.send_invite = send_invite + self.rbac = rbac + + def to_dict(self) -> dict: + return { + "loginId": self.login_id, + "attributes": self.attributes.to_dict() if self.attributes else None, + "sendInvite": self.send_invite, + "rbac": self.rbac.to_dict() if self.rbac else None, + } + + +def descopers_to_dict(descopers: List[DescoperCreate]) -> list: + return [d.to_dict() for d in descopers] diff --git a/descope/management/descoper.py b/descope/management/descoper.py new file mode 100644 index 000000000..4396b2333 --- /dev/null +++ b/descope/management/descoper.py @@ -0,0 +1,153 @@ +from typing import List, Optional, Any + +from descope._http_base import HTTPBase +from descope.management.common import ( + DescoperAttributes, + DescoperCreate, + DescoperRBAC, + MgmtV1, + descopers_to_dict, +) + + +class Descoper(HTTPBase): + def create( + self, + descopers: List[DescoperCreate], + ) -> dict: + """ + Create new Descopers. + + Args: + descopers (List[DescoperCreate]): List of Descopers to create. + Note that tags are referred to by name, without the company ID prefix. + + Return value (dict): + Return dict in the format + { + "descopers": [...], + "total": + } + + Raise: + AuthException: raised if create operation fails + """ + if not descopers: + raise ValueError("descopers list cannot be empty") + + response = self._http.put( + MgmtV1.descoper_create_path, + body={"descopers": descopers_to_dict(descopers)}, + ) + return response.json() + + def update( + self, + id: str, + attributes: Optional[DescoperAttributes] = None, + rbac: Optional[DescoperRBAC] = None, + ) -> dict: + """ + Update an existing Descoper's RBAC and/or Attributes. + + IMPORTANT: All parameter *fields*, if set, will override whatever values are currently set + in the existing Descoper. Use carefully. + + Args: + id (str): The id of the Descoper to update. + attributes (DescoperAttributes): Optional attributes to update. + rbac (DescoperRBAC): Optional RBAC configuration to update. + + Return value (dict): + Return dict in the format + {"descoper": {...}} + Containing the updated Descoper information. + + Raise: + AuthException: raised if update operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + body: dict[str, Any] = {"id": id} + if attributes is not None: + body["attributes"] = attributes.to_dict() + if rbac is not None: + body["rbac"] = rbac.to_dict() + + response = self._http.patch( + MgmtV1.descoper_update_path, + body=body, + ) + return response.json() + + def load( + self, + id: str, + ) -> dict: + """ + Load an existing Descoper by ID. + + Args: + id (str): The id of the Descoper to load. + + Return value (dict): + Return dict in the format + {"descoper": {...}} + Containing the loaded Descoper information. + + Raise: + AuthException: raised if load operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + response = self._http.get( + uri=MgmtV1.descoper_load_path, + params={"id": id}, + ) + return response.json() + + def delete( + self, + id: str, + ): + """ + Delete an existing Descoper. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The id of the Descoper to delete. + + Raise: + AuthException: raised if delete operation fails + """ + if not id: + raise ValueError("id cannot be empty") + + self._http.delete( + uri=MgmtV1.descoper_delete_path, + params={"id": id}, + ) + + def list( + self, + ) -> dict: + """ + List all Descopers. + + Return value (dict): + Return dict in the format + { + "descopers": [...], + "total": + } + Containing all Descopers and the total count. + + Raise: + AuthException: raised if list operation fails + """ + response = self._http.post( + MgmtV1.descoper_list_path, + body={}, + ) + return response.json() diff --git a/descope/mgmt.py b/descope/mgmt.py index b1c36088c..3bf29387e 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -6,6 +6,7 @@ from descope.management.access_key import AccessKey from descope.management.audit import Audit from descope.management.authz import Authz +from descope.management.descoper import Descoper from descope.management.fga import FGA from descope.management.flow import Flow from descope.management.group import Group @@ -40,6 +41,7 @@ def __init__( self._access_key = AccessKey(http_client) self._audit = Audit(http_client) self._authz = Authz(http_client) + self._descoper = Descoper(http_client) self._fga = FGA(http_client, fga_cache_url=fga_cache_url) self._flow = Flow(http_client) self._group = Group(http_client) @@ -141,3 +143,8 @@ def outbound_application(self): def outbound_application_by_token(self): # No management key check for outbound_app_token (as authentication for those methods is done by inbound app token) return self._outbound_application_by_token + + @property + def descoper(self): + self._ensure_management_key("descoper") + return self._descoper diff --git a/tests/management/test_descoper.py b/tests/management/test_descoper.py new file mode 100644 index 000000000..5a96f803f --- /dev/null +++ b/tests/management/test_descoper.py @@ -0,0 +1,430 @@ +import json +from unittest import mock +from unittest.mock import patch + +from descope import ( + AuthException, + DescoperAttributes, + DescoperCreate, + DescoperProjectRole, + DescoperRBAC, + DescoperRole, + DescopeClient, +) +from descope.common import DEFAULT_TIMEOUT_SECONDS +from descope.management.common import MgmtV1 + +from .. import common + + +class TestDescoper(common.DescopeTest): + def setUp(self) -> None: + super().setUp() + self.dummy_project_id = "dummy" + self.dummy_management_key = "key" + + def test_create(self): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.put") as mock_put: + mock_put.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.descoper.create, + [ + DescoperCreate( + login_id="user1@example.com", + ) + ], + ) + + # Test empty descopers + self.assertRaises( + ValueError, + client.mgmt.descoper.create, + [], + ) + + # Test success flow + with patch("requests.put") as mock_put: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{ + "descopers": [{ + "id": "U2111111111111111111111111", + "attributes": { + "displayName": "Test User 2", + "email": "user2@example.com", + "phone": "+123456" + }, + "rbac": { + "isCompanyAdmin": false, + "tags": [], + "projects": [{ + "projectIds": ["P2111111111111111111111111"], + "role": "admin" + }] + }, + "status": "invited" + }], + "total": 1 + }""" + ) + mock_put.return_value = network_resp + resp = client.mgmt.descoper.create( + descopers=[ + DescoperCreate( + login_id="user1@example.com", + attributes=DescoperAttributes( + display_name="Test User 2", + phone="+123456", + email="user2@example.com", + ), + rbac=DescoperRBAC( + projects=[ + DescoperProjectRole( + project_ids=["P2111111111111111111111111"], + role=DescoperRole.ADMIN, + ) + ], + ), + ) + ], + ) + descopers = resp["descopers"] + self.assertEqual(len(descopers), 1) + self.assertEqual(descopers[0]["id"], "U2111111111111111111111111") + self.assertEqual(resp["total"], 1) + mock_put.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_create_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={ + "descopers": [ + { + "loginId": "user1@example.com", + "attributes": { + "displayName": "Test User 2", + "email": "user2@example.com", + "phone": "+123456", + }, + "sendInvite": False, + "rbac": { + "isCompanyAdmin": False, + "tags": [], + "projects": [ + { + "projectIds": ["P2111111111111111111111111"], + "role": "admin", + } + ], + }, + } + ] + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_load(self): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.get") as mock_get: + mock_get.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.descoper.load, + "descoper-id", + ) + + # Test empty id + self.assertRaises( + ValueError, + client.mgmt.descoper.load, + "", + ) + + # Test success flow + with patch("requests.get") as mock_get: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{ + "descoper": { + "id": "U2222222222222222222222222", + "attributes": { + "displayName": "Test User 2", + "email": "user2@example.com", + "phone": "+123456" + }, + "rbac": { + "isCompanyAdmin": false, + "tags": [], + "projects": [{ + "projectIds": ["P2111111111111111111111111"], + "role": "admin" + }] + }, + "status": "invited" + } + }""" + ) + mock_get.return_value = network_resp + resp = client.mgmt.descoper.load("U2222222222222222222222222") + descoper = resp["descoper"] + self.assertEqual(descoper["id"], "U2222222222222222222222222") + mock_get.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_load_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={"id": "U2222222222222222222222222"}, + allow_redirects=True, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_update(self): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.patch") as mock_patch: + mock_patch.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.descoper.update, + "descoper-id", + None, + DescoperRBAC(is_company_admin=True), + ) + + # Test empty id + self.assertRaises( + ValueError, + client.mgmt.descoper.update, + "", + ) + + # Test success flow + with patch("requests.patch") as mock_patch: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{ + "descoper": { + "id": "U2333333333333333333333333", + "attributes": { + "displayName": "Updated User", + "email": "user4@example.com", + "phone": "+1234358730" + }, + "rbac": { + "isCompanyAdmin": true, + "tags": [], + "projects": [] + }, + "status": "invited" + } + }""" + ) + mock_patch.return_value = network_resp + resp = client.mgmt.descoper.update( + "U2333333333333333333333333", + None, + DescoperRBAC(is_company_admin=True), + ) + descoper = resp["descoper"] + self.assertEqual(descoper["id"], "U2333333333333333333333333") + self.assertTrue(descoper["rbac"]["isCompanyAdmin"]) + mock_patch.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_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": "U2333333333333333333333333", + "rbac": { + "isCompanyAdmin": True, + "tags": [], + "projects": [], + }, + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_delete(self): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.delete") as mock_delete: + mock_delete.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.descoper.delete, + "descoper-id", + ) + + # Test empty id + self.assertRaises( + ValueError, + client.mgmt.descoper.delete, + "", + ) + + # Test success flow + with patch("requests.delete") as mock_delete: + mock_delete.return_value.ok = True + self.assertIsNone(client.mgmt.descoper.delete("U2111111111111111111111111")) + mock_delete.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_delete_path}", + params={"id": "U2111111111111111111111111"}, + headers={ + **common.default_headers, + "Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}", + "x-descope-project-id": self.dummy_project_id, + }, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def test_list(self): + client = DescopeClient( + self.dummy_project_id, + None, + False, + self.dummy_management_key, + ) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.descoper.list, + ) + + # Test success flow + with patch("requests.post") as mock_post: + network_resp = mock.Mock() + network_resp.ok = True + network_resp.json.return_value = json.loads( + """{ + "descopers": [ + { + "id": "U2444444444444444444444444", + "attributes": { + "displayName": "Admin User", + "email": "admin@example.com", + "phone": "" + }, + "rbac": { + "isCompanyAdmin": true, + "tags": [], + "projects": [] + }, + "status": "enabled" + }, + { + "id": "U2555555555555555555555555", + "attributes": { + "displayName": "Another User", + "email": "user3@example.com", + "phone": "+123456" + }, + "rbac": { + "isCompanyAdmin": false, + "tags": [], + "projects": [] + }, + "status": "invited" + }, + { + "id": "U2666666666666666666666666", + "attributes": { + "displayName": "Test User 1", + "email": "user2@example.com", + "phone": "+123456" + }, + "rbac": { + "isCompanyAdmin": false, + "tags": [], + "projects": [{ + "projectIds": ["P2222222222222222222222222"], + "role": "admin" + }] + }, + "status": "invited" + } + ], + "total": 3 + }""" + ) + mock_post.return_value = network_resp + resp = client.mgmt.descoper.list() + descopers = resp["descopers"] + self.assertEqual(len(descopers), 3) + self.assertEqual(resp["total"], 3) + + # First descoper - company admin + self.assertEqual(descopers[0]["id"], "U2444444444444444444444444") + self.assertEqual(descopers[0]["attributes"]["displayName"], "Admin User") + self.assertTrue(descopers[0]["rbac"]["isCompanyAdmin"]) + self.assertEqual(descopers[0]["status"], "enabled") + + # Second descoper + self.assertEqual(descopers[1]["id"], "U2555555555555555555555555") + self.assertFalse(descopers[1]["rbac"]["isCompanyAdmin"]) + + # Third descoper - with project role + self.assertEqual(descopers[2]["id"], "U2666666666666666666666666") + self.assertEqual(len(descopers[2]["rbac"]["projects"]), 1) + + mock_post.assert_called_with( + f"{common.DEFAULT_BASE_URL}{MgmtV1.descoper_list_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={}, + allow_redirects=False, + verify=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + )