diff --git a/.gitignore b/.gitignore index e72a38f0..3e83a497 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dmypy.json .pyre/ .vscode/ + +# Mac OS +.DS_Store diff --git a/descope/__init__.py b/descope/__init__.py index 8e50a212..d7d4a8df 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -8,3 +8,5 @@ ) from descope.descope_client import DescopeClient from descope.exceptions import AuthException +from descope.management.sso_settings import RoleMapping +from descope.management.user import UserTenants diff --git a/descope/authmethod/otp.py b/descope/authmethod/otp.py index a0c44242..51bed1e6 100644 --- a/descope/authmethod/otp.py +++ b/descope/authmethod/otp.py @@ -79,8 +79,8 @@ def sign_up_or_in(self, method: DeliveryMethod, identifier: str) -> None: """ Sign_up_or_in lets you handle both sign up and sign in with a single call. Sign-up_or_in will first determine if identifier is a new or existing end user. If identifier is new, a new end user user will be created and then - authenticated using the OTP DeliveryMethod specififed. If identifier exists, the end user will be authenticated - using the OTP DelieryMethod specified. + authenticated using the OTP DeliveryMethod specified. If identifier exists, the end user will be authenticated + using the OTP DeliveryMethod specified. Args: method (DeliveryMethod): The method to use for delivering the OTP verification code, for example phone or email @@ -100,7 +100,7 @@ def sign_up_or_in(self, method: DeliveryMethod, identifier: str) -> None: def verify_code(self, method: DeliveryMethod, identifier: str, code: str) -> dict: """ - Verify the valdity of an OTP code entered by an end user during sign_in or sign_up. + Verify the validity of an OTP code entered by an end user during sign_in or sign_up. (This function is not needed if you are using the sign_up_or_in function. Args: diff --git a/descope/descope_client.py b/descope/descope_client.py index 8ef36bb5..67c4089f 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -11,6 +11,7 @@ from descope.authmethod.webauthn import WebauthN # noqa: F401 from descope.common import SESSION_TOKEN_NAME, EndpointsV1 from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException +from descope.mgmt import MGMT # noqa: F401 class DescopeClient: @@ -24,6 +25,7 @@ def __init__( ): auth = Auth(project_id, public_key, skip_verify) self._auth = auth + self._mgmt = MGMT(auth) self._magiclink = MagicLink(auth) self._oauth = OAuth(auth) self._saml = SAML(auth) @@ -31,6 +33,10 @@ def __init__( self._totp = TOTP(auth) self._webauthn = WebauthN(auth) + @property + def mgmt(self): + return self._mgmt + @property def magiclink(self): return self._magiclink diff --git a/descope/management/common.py b/descope/management/common.py new file mode 100644 index 00000000..7ab476da --- /dev/null +++ b/descope/management/common.py @@ -0,0 +1,15 @@ +class MgmtV1: + # tenant + tenantCreatePath = "/v1/mgmt/tenant/create" + tenantUpdatePath = "/v1/mgmt/tenant/update" + tenantDeletePath = "/v1/mgmt/tenant/delete" + + # user + userCreatePath = "/v1/mgmt/user/create" + userUpdatePath = "/v1/mgmt/user/update" + userDeletePath = "/v1/mgmt/user/delete" + + # sso + ssoConfigurePath = "/v1/mgmt/sso/settings" + ssoMetadataPath = "/v1/mgmt/sso/metadata" + ssoRoleMappingPath = "/v1/mgmt/sso/roles" diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py new file mode 100644 index 00000000..867b160b --- /dev/null +++ b/descope/management/sso_settings.py @@ -0,0 +1,143 @@ +from typing import List + +from descope.auth import Auth +from descope.management.common import MgmtV1 + + +class RoleMapping: + def __init__(self, groups: List[str], role_name: str): + self.groups = groups + self.role_name = role_name + + +class SSOSettings: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + + def configure( + self, + mgmt_key: str, + tenant_id: str, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str = None, + ) -> None: + """ + Configure SSO setting for a tenant manually. Alternatively, `configure_via_metadata` can be used instead. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + tenant_id (str): The tenant ID to be configured + idp_url (str): The URL for the identity provider. + entity_id (str): The entity ID (in the IDP). + idp_cert (str): The certificate provided by the IDP. + redirect_url (str): An Optional Redirect URL after successful authentication. + + Raise: + AuthException: raised if configuration operation fails + """ + self._auth.do_post( + MgmtV1.ssoConfigurePath, + SSOSettings._compose_configure_body( + tenant_id, idp_url, entity_id, idp_cert, redirect_url + ), + pswd=mgmt_key, + ) + + def configure_via_metadata( + self, + mgmt_key: str, + tenant_id: str, + idp_metadata_url: str, + ): + """ + Configure SSO setting for am IDP metadata URL. Alternatively, `configure` can be used instead. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + tenant_id (str): The tenant ID to be configured + enabled (bool): Is SSO enabled + idp_metadata_url (str): The URL to fetch SSO settings from. + + Raise: + AuthException: raised if configuration operation fails + """ + self._auth.do_post( + MgmtV1.ssoMetadataPath, + SSOSettings._compose_metadata_body(tenant_id, idp_metadata_url), + pswd=mgmt_key, + ) + + def map_roles( + self, + mgmt_key: str, + tenant_id: str, + role_mappings: List[RoleMapping], + ): + """ + Configure SSO role mapping from the IDP groups to the Descope roles. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + tenant_id (str): The tenant ID to be configured + role_mappings (List[RoleMapping]): A mapping between IDP groups and Descope roles. + + Raise: + AuthException: raised if configuration operation fails + """ + self._auth.do_post( + MgmtV1.ssoRoleMappingPath, + SSOSettings._compose_role_mapping_body(tenant_id, role_mappings), + pswd=mgmt_key, + ) + + @staticmethod + def _compose_configure_body( + tenant_id: str, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str = None, + ) -> dict: + return { + "tenantId": tenant_id, + "idpURL": idp_url, + "entityId": entity_id, + "idpCert": idp_cert, + "redirectURL": redirect_url, + } + + @staticmethod + def _compose_metadata_body( + tenant_id: str, + idp_metadata_url: str, + ) -> dict: + return { + "tenantId": tenant_id, + "idpMetadataURL": idp_metadata_url, + } + + @staticmethod + def _compose_role_mapping_body( + tenant_id: str, + role_mapping: List[RoleMapping], + ) -> dict: + return { + "tenantId": tenant_id, + "roleMapping": SSOSettings._role_mapping_to_dict(role_mapping), + } + + @staticmethod + def _role_mapping_to_dict(role_mapping: List[RoleMapping]) -> list: + role_mapping_list = [] + for mapping in role_mapping: + role_mapping_list.append( + { + "groups": mapping.groups, + "roleName": mapping.role_name, + } + ) + return role_mapping_list diff --git a/descope/management/tenant.py b/descope/management/tenant.py new file mode 100644 index 00000000..4e7730cc --- /dev/null +++ b/descope/management/tenant.py @@ -0,0 +1,96 @@ +from typing import List + +from descope.auth import Auth +from descope.management.common import MgmtV1 + + +class Tenant: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + + def create( + self, + mgmt_key: str, + name: str, + id: str = None, + self_provisioning_domains: List[str] = [], + ) -> dict: + """ + Create a new tenant with the given name. Tenant IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + name (str): The tenant's name + id (str): Optional tenant ID. + self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + + Raise: + AuthException: raised if creation operation fails + """ + uri = MgmtV1.tenantCreatePath + response = self._auth.do_post( + uri, + Tenant._compose_create_update_body(name, id, self_provisioning_domains), + pswd=mgmt_key, + ) + return response.json() + + def update( + self, + mgmt_key: str, + id: str, + name: str, + self_provisioning_domains: List[str] = [], + ): + """ + Update an existing tenant with the given name and domains. IMPORTANT: All parameters are used as overrides + to the existing tenant. Empty fields will override populated fields. Use carefully. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + id (str): The ID of the tenant to update. + name (str): Updated tenant name + self_provisioning_domains (List[str]): An optional list of domain that are associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + + Raise: + AuthException: raised if creation operation fails + """ + uri = MgmtV1.tenantUpdatePath + self._auth.do_post( + uri, + Tenant._compose_create_update_body(name, id, self_provisioning_domains), + pswd=mgmt_key, + ) + + def delete( + self, + mgmt_key: str, + id: str, + ): + """ + Delete an existing tenant. IMPORTANT: This action is irreversible. Use carefully. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + id (str): The ID of the tenant that's to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + uri = MgmtV1.tenantDeletePath + self._auth.do_post(uri, {"id": id}, pswd=mgmt_key) + + @staticmethod + def _compose_create_update_body( + name: str, id: str, self_provisioning_domains: List[str] + ) -> dict: + return { + "name": name, + "id": id, + "selfProvisioningDomains": self_provisioning_domains, + } diff --git a/descope/management/user.py b/descope/management/user.py new file mode 100644 index 00000000..7dfba7da --- /dev/null +++ b/descope/management/user.py @@ -0,0 +1,140 @@ +from typing import List + +from descope.auth import Auth +from descope.management.common import MgmtV1 + + +class UserTenants: + def __init__(self, tenant_id: str, role_names: List[str] = []): + self.tenant_id = tenant_id + self.role_names = role_names + + +class User: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + + def create( + self, + mgmt_key: str, + identifier: str, + email: str = None, + phone_number: str = None, + display_name: str = None, + role_names: List[str] = [], + user_tenants: List[UserTenants] = [], + ) -> dict: + """ + Create a new user. Users can have any number of optional fields, including email, phone number and authorization. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + identifier (str): user identifier. + email (str): Optional user email address. + phone_number (str): Optional user phone number. + display_name (str): Optional user display name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles, which take precedence over them. + user_tenants (List[UserTenants]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`, and take precedence over them. + + Raise: + AuthException: raised if creation operation fails + """ + self._auth.do_post( + MgmtV1.userCreatePath, + User._compose_create_update_body( + identifier, email, phone_number, display_name, role_names, user_tenants + ), + pswd=mgmt_key, + ) + + def update( + self, + mgmt_key: str, + identifier: str, + email: str = None, + phone_number: str = None, + display_name: str = None, + role_names: List[str] = [], + user_tenants: List[UserTenants] = [], + ): + """ + Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides + to the existing user. Empty fields will override populated fields. Use carefully. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + identifier (str): The identifier of the user to update. + email (str): Optional user email address. + phone_number (str): Optional user phone number. + display_name (str): Optional user display name. + role_names (List[str]): An optional list of the user's roles without tenant association. These roles are + mutually exclusive with the `user_tenant` roles, which take precedence over the general roles. + user_tenants (List[UserTenants]): An optional list of the user's tenants, and optionally, their roles per tenant. These roles are + mutually exclusive with the general `role_names`, and take precedence over them. + + Raise: + AuthException: raised if creation operation fails + """ + self._auth.do_post( + MgmtV1.userUpdatePath, + User._compose_create_update_body( + identifier, email, phone_number, display_name, role_names, user_tenants + ), + pswd=mgmt_key, + ) + + def delete( + self, + mgmt_key: str, + identifier: str, + ): + """ + Delete an existing user. IMPORTANT: This action is irreversible. Use carefully. + + Args: + mgmt_key (str): A management key generated in the Descope console. All management functions require it. + identifier (str): The identifier of the user that's to be deleted. + + Raise: + AuthException: raised if creation operation fails + """ + self._auth.do_post( + MgmtV1.userDeletePath, + {"identifier": identifier}, + pswd=mgmt_key, + ) + + @staticmethod + def _compose_create_update_body( + identifier: str, + email: str, + phone_number: str, + display_name: str, + role_names: List[str], + user_tenants: List[UserTenants], + ) -> dict: + return { + "identifier": identifier, + "email": email, + "phoneNumber": phone_number, + "displayName": display_name, + "roleNames": role_names, + "userTenants": User._user_tenants_to_dict(user_tenants), + } + + @staticmethod + def _user_tenants_to_dict(user_tenants: List[UserTenants]) -> list: + user_tenant_list = [] + if user_tenants: + for user_tenant in user_tenants: + user_tenant_list.append( + { + "tenantId": user_tenant.tenant_id, + "roleNames": user_tenant.role_names, + } + ) + return user_tenant_list diff --git a/descope/mgmt.py b/descope/mgmt.py new file mode 100644 index 00000000..6aba5215 --- /dev/null +++ b/descope/mgmt.py @@ -0,0 +1,26 @@ +from descope.auth import Auth +from descope.management.sso_settings import SSOSettings # noqa: F401 +from descope.management.tenant import Tenant # noqa: F401 +from descope.management.user import User # noqa: F401 + + +class MGMT: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + self._tenant = Tenant(auth) + self._user = User(auth) + self._sso = SSOSettings(auth) + + @property + def tenant(self): + return self._tenant + + @property + def user(self): + return self._user + + @property + def sso(self): + return self._sso diff --git a/samples/management_sso_sample_app.py b/samples/management_sso_sample_app.py new file mode 100644 index 00000000..391da853 --- /dev/null +++ b/samples/management_sso_sample_app.py @@ -0,0 +1,65 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException, DescopeClient, RoleMapping # noqa: E402 + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + mgmt_key = "" + tenant_id = "" + + try: + descope_client = DescopeClient(project_id=project_id) + idp_url = "" + entity_id = "" + idp_cert = "" + idp_metadata_url = "" + role_mappings = [RoleMapping([], "Tenant Admin")] + + try: + logging.info("Configure SSO for tenant") + descope_client.mgmt.sso.configure( + mgmt_key, + tenant_id, + idp_url=idp_url, + entity_id=entity_id, + idp_cert=idp_cert, + ) + + except AuthException as e: + logging.info(f"SSO configuration failed {e}") + + try: + logging.info("Configure SSO for tenant via metadata") + descope_client.mgmt.sso.configure_via_metadata( + mgmt_key, + tenant_id, + idp_metadata_url=idp_metadata_url, + ) + + except AuthException as e: + logging.info(f"SSO configuration failed via metadata {e}") + + try: + logging.info("Update tenant role mappings") + descope_client.mgmt.sso.map_roles( + mgmt_key, + tenant_id, + role_mappings == role_mappings, + ) + + except AuthException as e: + logging.info(f"SSO role mapping failed {e}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/management_tenant_sample_app.py b/samples/management_tenant_sample_app.py new file mode 100644 index 00000000..d4fb89ca --- /dev/null +++ b/samples/management_tenant_sample_app.py @@ -0,0 +1,52 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException, DescopeClient # noqa: E402 + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + mgmt_key = "" + + try: + descope_client = DescopeClient(project_id=project_id) + tenant_id = "" + + try: + logging.info("Going to create a new tenant") + resp = descope_client.mgmt.tenant.create(mgmt_key, "My First Tenant") + tenant_id = resp["id"] + logging.info(f"Tenant creation response: {resp}") + + except AuthException as e: + logging.info(f"Tenant creation failed {e}") + + try: + logging.info("Updating newly created tenant") + # update overrides all fields, must provide the entire entity + # we mean to update. + descope_client.mgmt.tenant.update( + mgmt_key, tenant_id, "My First Tenant", ["mydomain.com"] + ) + + except AuthException as e: + logging.info(f"Tenant update failed {e}") + + try: + logging.info("Deleting newly created tenant") + descope_client.mgmt.tenant.delete(mgmt_key, tenant_id) + + except AuthException as e: + logging.info(f"Tenant deletion failed {e}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/samples/management_user_sample_app.py b/samples/management_user_sample_app.py new file mode 100644 index 00000000..98059818 --- /dev/null +++ b/samples/management_user_sample_app.py @@ -0,0 +1,50 @@ +import logging +import os +import sys + +dir_name = os.path.dirname(__file__) +sys.path.insert(0, os.path.join(dir_name, "../")) +from descope import AuthException, DescopeClient # noqa: E402 + +logging.basicConfig(level=logging.INFO) + + +def main(): + project_id = "" + mgmt_key = "" + + try: + descope_client = DescopeClient(project_id=project_id) + user_identifier = "des@copeland.com" + + try: + logging.info("Going to create a new user") + descope_client.mgmt.user.create(mgmt_key, user_identifier) + + except AuthException as e: + logging.info(f"User creation failed {e}") + + try: + logging.info("Updating newly created user") + # update overrides all fields, must provide the entire entity + # we mean to update. + descope_client.mgmt.user.update( + mgmt_key, user_identifier, display_name="Desmond Copeland" + ) + + except AuthException as e: + logging.info(f"User update failed {e}") + + try: + logging.info("Deleting newly created tenant") + descope_client.mgmt.user.delete(mgmt_key, user_identifier) + + except AuthException as e: + logging.info(f"User deletion failed {e}") + + except AuthException: + raise + + +if __name__ == "__main__": + main() diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py new file mode 100644 index 00000000..5be41d1d --- /dev/null +++ b/tests/management/test_sso_settings.py @@ -0,0 +1,112 @@ +import unittest +from unittest.mock import patch + +from descope import AuthException, DescopeClient, RoleMapping + + +class TestSSOSettings(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_configure(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.sso.configure, + "valid-key", + "tenant-id", + "https://idp.com", + "entity-id", + "cert", + "https://redirect.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.sso.configure( + "valid-key", + "tenant-id", + "https://idp.com", + "entity-id", + "cert", + "https://redirect.com", + ) + ) + + # Redirect is optional + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.sso.configure( + "valid-key", + "tenant-id", + "https://idp.com", + "entity-id", + "cert", + ) + ) + + def test_configure_via_metadata(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.sso.configure_via_metadata, + "valid-key", + "tenant-id", + "https://idp-meta.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.sso.configure_via_metadata( + "valid-key", + "tenant-id", + "https://idp-meta.com", + ) + ) + + def test_role_mapping(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.sso.map_roles, + "valid-key", + "tenant-id", + [RoleMapping(["a", "b"], "role")], + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.sso.map_roles( + "valid-key", + "tenant-id", + [RoleMapping(["a", "b"], "role")], + ) + ) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py new file mode 100644 index 00000000..b1a17e00 --- /dev/null +++ b/tests/management/test_tenant.py @@ -0,0 +1,81 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException, DescopeClient + + +class TestTenant(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_create(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.tenant.create, + "valid-key", + "valid-name", + ) + + # 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("""{"id": "t1"}""") + mock_post.return_value = network_resp + resp = client.mgmt.tenant.create("key", "name", "t1", ["domain.com"]) + self.assertEqual(resp["id"], "t1") + + def test_update(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.tenant.update, + "valid-key", + "valid-id", + "valid-name", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.tenant.update("key", "t1", "new-name", ["domain.com"]) + ) + + def test_delete(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.tenant.delete, + "valid-key", + "valid-id", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone(client.mgmt.tenant.delete("key", "t1")) diff --git a/tests/management/test_user.py b/tests/management/test_user.py new file mode 100644 index 00000000..af9cffc8 --- /dev/null +++ b/tests/management/test_user.py @@ -0,0 +1,91 @@ +import unittest +from unittest.mock import patch + +from descope import AuthException, DescopeClient, UserTenants + + +class TestUser(unittest.TestCase): + def setUp(self) -> None: + self.dummy_project_id = "dummy" + self.public_key_dict = { + "alg": "ES384", + "crv": "P-384", + "kid": "P2CtzUhdqpIF2ys9gg7ms06UvtC4", + "kty": "EC", + "use": "sig", + "x": "pX1l7nT2turcK5_Cdzos8SKIhpLh1Wy9jmKAVyMFiOCURoj-WQX1J0OUQqMsQO0s", + "y": "B0_nWAv2pmG_PzoH3-bSYZZzLNKUA0RoE2SH7DaS0KV4rtfWZhYd0MEr0xfdGKx0", + } + + def test_create(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.user.create, + "valid-key", + "valid-identifier", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.user.create( + mgmt_key="key", + identifier="name@mail.com", + email="name@mail.com", + display_name="Name", + user_tenants=[ + UserTenants("tenant1"), + UserTenants("tenant2", ["role1", "role2"]), + ], + ) + ) + + def test_update(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.user.update, + "valid-key", + "valid-identifier", + "email@something.com", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone( + client.mgmt.user.update( + "key", + "identifier", + display_name="new-name", + role_names=["domain.com"], + ) + ) + + def test_delete(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + with patch("requests.post") as mock_post: + mock_post.return_value.ok = False + self.assertRaises( + AuthException, + client.mgmt.user.delete, + "valid-key", + "valid-id", + ) + + # Test success flow + with patch("requests.post") as mock_post: + mock_post.return_value.ok = True + self.assertIsNone(client.mgmt.user.delete("key", "t1"))