From 45598dd0ec445a21fd33caa1e3208760d4da49d8 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Sun, 20 Nov 2022 10:16:55 +0200 Subject: [PATCH 01/10] Add tenant management functionality --- .gitignore | 3 + descope/descope_client.py | 6 ++ descope/management/common.py | 5 ++ descope/management/tenant.py | 94 ++++++++++++++++++++++++ descope/mgmt.py | 14 ++++ tests/management/test_tenant.py | 125 ++++++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 descope/management/common.py create mode 100644 descope/management/tenant.py create mode 100644 descope/mgmt.py create mode 100644 tests/management/test_tenant.py 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/descope_client.py b/descope/descope_client.py index 8ef36bb5..3fda189b 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -3,6 +3,7 @@ import requests from descope.auth import Auth # noqa: F401 +from descope.mgmt import MGMT # noqa: F401 from descope.authmethod.magiclink import MagicLink # noqa: F401 from descope.authmethod.oauth import OAuth # noqa: F401 from descope.authmethod.otp import OTP # noqa: F401 @@ -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..9e7a5d70 --- /dev/null +++ b/descope/management/common.py @@ -0,0 +1,5 @@ +class MgmtV1: + # tenant + tenantCreatePath = "/v1/mgmt/tenant/create" + tenantUpdatePath = "/v1/mgmt/tenant/update" + tenantDeletePath = "/v1/mgmt/tenant/delete" diff --git a/descope/management/tenant.py b/descope/management/tenant.py new file mode 100644 index 00000000..04b0ceab --- /dev/null +++ b/descope/management/tenant.py @@ -0,0 +1,94 @@ +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] = None, + ) -> 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, _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 = None, + self_provisioning_domains: List[str] = None, + ): + """ + Update an existing tenant with the given name and domains. IMPORTANT: All parameters are used as overrides + to the existing tenant. Use carefully. + + 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.tenantUpdatePath + self._auth.do_post( + uri, _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/mgmt.py b/descope/mgmt.py new file mode 100644 index 00000000..8c72526a --- /dev/null +++ b/descope/mgmt.py @@ -0,0 +1,14 @@ +from descope.auth import Auth +from descope.management.tenant import Tenant # noqa: F401 + + +class MGMT: + _auth: Auth + + def __init__(self, auth: Auth): + self._auth = auth + self._tenant = Tenant(auth) + + @property + def tenant(self): + return self._tenant diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py new file mode 100644 index 00000000..4dfe4159 --- /dev/null +++ b/tests/management/test_tenant.py @@ -0,0 +1,125 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException, DescopeClient +from descope.common import REFRESH_SESSION_COOKIE_NAME, LoginOptions + + +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 + self.assertRaises( + AuthException, + client.mgmt.tenant.create, + "", + "valid-name", + ) + self.assertRaises( + AuthException, + client.mgmt.tenant.create, + "valid-key", + "", + ) + + 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 + self.assertRaises( + AuthException, + client.mgmt.tenant.update, + "", + "valid-id", + "valid-name", + ) + self.assertRaises( + AuthException, + client.mgmt.tenant.update, + "valid-key", + "", + "valid-name", + ) + + 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 + self.assertRaises( + AuthException, + client.mgmt.tenant.delete, + "", + "valid-id", + ) + self.assertRaises( + AuthException, + client.mgmt.tenant.delete, + "valid-key", + "", + ) + + 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") + ) From 973672041e063124ad388fbdbd20c468a3ae0f4a Mon Sep 17 00:00:00 2001 From: itaihanski Date: Sun, 20 Nov 2022 15:46:48 +0200 Subject: [PATCH 02/10] Add user management functionality --- descope/__init__.py | 1 + descope/management/common.py | 5 ++ descope/management/user.py | 142 ++++++++++++++++++++++++++++++++++ descope/mgmt.py | 6 ++ tests/management/test_user.py | 121 +++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 descope/management/user.py create mode 100644 tests/management/test_user.py diff --git a/descope/__init__.py b/descope/__init__.py index 8e50a212..616bef78 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -8,3 +8,4 @@ ) from descope.descope_client import DescopeClient from descope.exceptions import AuthException +from descope.management.user import UserTenants diff --git a/descope/management/common.py b/descope/management/common.py index 9e7a5d70..9fd1707b 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -3,3 +3,8 @@ class MgmtV1: 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" diff --git a/descope/management/user.py b/descope/management/user.py new file mode 100644 index 00000000..ecfa37d1 --- /dev/null +++ b/descope/management/user.py @@ -0,0 +1,142 @@ +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] = None, + user_tenants: List[UserTenants] = None + ) -> dict: + """ + Create a new user. User's 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. + user_tenants (List[str]): An optional list of the user's roles per tenant. + + Raise: + AuthException: raised if creation operation fails + """ + self._auth.do_post( + MgmtV1.userCreatePath, + _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] = None, + user_tenants: List[UserTenants] = None + ): + """ + Update an existing user with the given name and domains. IMPORTANT: All parameters are used as overrides + to the existing user. Use carefully. + + 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. + user_tenants (List[str]): An optional list of the user's roles per tenant. + + Raise: + AuthException: raised if creation operation fails + """ + self._auth.do_post( + MgmtV1.userUpdatePath, + _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, + ) + +class UserTenants: + def __init__( + self, tenant_id: str, role_names: List[str] + ): + self.tenant_id = tenant_id + self.role_names = role_names + +@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_tenants_to_dict(user_tenants) + } + +@staticmethod +def _user_tenants_to_dict(user_tenants: List[UserTenants]) -> list: + if not user_tenants: + return None + + user_tenant_list = [] + 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 index 8c72526a..8baf309b 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -1,5 +1,6 @@ from descope.auth import Auth from descope.management.tenant import Tenant # noqa: F401 +from descope.management.user import User # noqa: F401 class MGMT: @@ -8,7 +9,12 @@ class MGMT: def __init__(self, auth: Auth): self._auth = auth self._tenant = Tenant(auth) + self._user = User(auth) @property def tenant(self): return self._tenant + + @property + def user(self): + return self._user diff --git a/tests/management/test_user.py b/tests/management/test_user.py new file mode 100644 index 00000000..aa1edf72 --- /dev/null +++ b/tests/management/test_user.py @@ -0,0 +1,121 @@ +import json +import unittest +from unittest import mock +from unittest.mock import patch + +from descope import AuthException, DescopeClient +from descope.common import REFRESH_SESSION_COOKIE_NAME, LoginOptions + + +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 + self.assertRaises( + AuthException, + client.mgmt.user.create, + "", + "valid-identifier", + ) + self.assertRaises( + AuthException, + client.mgmt.user.create, + "valid-key", + "", + ) + + 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("key", "identifier", display_name="name") + ) + + def test_update(self): + client = DescopeClient(self.dummy_project_id, self.public_key_dict) + + # Test failed flows + self.assertRaises( + AuthException, + client.mgmt.user.update, + "", + "valid-identifier", + ) + self.assertRaises( + AuthException, + client.mgmt.user.update, + "valid-key", + "", + ) + + 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 + self.assertRaises( + AuthException, + client.mgmt.user.delete, + "", + "valid-id", + ) + self.assertRaises( + AuthException, + client.mgmt.user.delete, + "valid-key", + "", + ) + + 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") + ) From 8629767f250be117e77a798cdf2768fd54d1f463 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Mon, 21 Nov 2022 16:58:09 +0200 Subject: [PATCH 03/10] Add SSO settings functionality and remove useless tests --- descope/__init__.py | 1 + descope/management/common.py | 5 + descope/management/sso_settings.py | 156 ++++++++++++++++++++++++++ descope/mgmt.py | 6 + tests/management/test_sso_settings.py | 103 +++++++++++++++++ tests/management/test_tenant.py | 42 ------- tests/management/test_user.py | 42 ------- 7 files changed, 271 insertions(+), 84 deletions(-) create mode 100644 descope/management/sso_settings.py create mode 100644 tests/management/test_sso_settings.py diff --git a/descope/__init__.py b/descope/__init__.py index 616bef78..66790175 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -9,3 +9,4 @@ from descope.descope_client import DescopeClient from descope.exceptions import AuthException from descope.management.user import UserTenants +from descope.management.sso_settings import RoleMapping diff --git a/descope/management/common.py b/descope/management/common.py index 9fd1707b..7ab476da 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -8,3 +8,8 @@ class MgmtV1: 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..6f96fa9f --- /dev/null +++ b/descope/management/sso_settings.py @@ -0,0 +1,156 @@ +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, + enabled: bool, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, + ) -> dict: + """ + 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 + enabled (str): Is SSO enabled + 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): Redirect URL after successful authentication. + + Raise: + AuthException: raised if configuration operation fails + """ + self._auth.do_post( + MgmtV1.ssoConfigurePath, + _compose_configure_body(tenant_id, enabled, idp_url, entity_id, idp_cert, redirect_url), + pswd=mgmt_key, + ) + + def configure_via_metadata( + self, + mgmt_key: str, + tenant_id: str, + enabled: bool, + 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 (str): 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, + _compose_metadata_body(tenant_id, enabled, idp_metadata_url), + pswd=mgmt_key, + ) + + def map_roles( + self, + mgmt_key: str, + tenant_id: str, + role_mapping: 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_mapping (List[RoleMapping]): A mapping between IDP groups and Descope roles. + + Raise: + AuthException: raised if configuration operation fails + """ + self._auth.do_post( + MgmtV1.ssoRoleMappingPath, + _compose_role_mapping_body(tenant_id, role_mapping), + pswd=mgmt_key, + ) + +class UserTenants: + def __init__( + self, tenant_id: str, role_names: List[str] + ): + self.tenant_id = tenant_id + self.role_names = role_names + +@staticmethod +def _compose_configure_body( + tenant_id: str, + enabled: bool, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, +) -> dict: + return { + "tenantId": tenant_id, + "enabled": enabled, + "idpURL": idp_url, + "entityId": entity_id, + "idpCert": idp_cert, + "redirectURL": redirect_url, + } + +@staticmethod +def _compose_metadata_body( + tenant_id: str, + enabled: bool, + idp_metadata_url: str, +) -> dict: + return { + "tenantId": tenant_id, + "enabled": enabled, + "idpMetadataURL": idp_metadata_url, + } + +@staticmethod +def _compose_role_mapping_body( + tenant_id: str, + role_mapping: List[RoleMapping], +) -> dict: + return { + "tenantId": tenant_id, + "roleMapping": _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/mgmt.py b/descope/mgmt.py index 8baf309b..f625aa3a 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -1,6 +1,7 @@ from descope.auth import Auth from descope.management.tenant import Tenant # noqa: F401 from descope.management.user import User # noqa: F401 +from descope.management.sso_settings import SSOSettings # noqa: F401 class MGMT: @@ -10,6 +11,7 @@ def __init__(self, auth: Auth): self._auth = auth self._tenant = Tenant(auth) self._user = User(auth) + self._sso = SSOSettings(auth) @property def tenant(self): @@ -18,3 +20,7 @@ def tenant(self): @property def user(self): return self._user + + @property + def sso(self): + return self._sso diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py new file mode 100644 index 00000000..133f3608 --- /dev/null +++ b/tests/management/test_sso_settings.py @@ -0,0 +1,103 @@ +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", + True, + "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", + True, + "https://idp.com", + "entity-id", + "cert", + "https://redirect.com", + ) + ) + + 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", + True, + "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", + True, + "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 index 4dfe4159..399f48a7 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -4,7 +4,6 @@ from unittest.mock import patch from descope import AuthException, DescopeClient -from descope.common import REFRESH_SESSION_COOKIE_NAME, LoginOptions class TestTenant(unittest.TestCase): @@ -24,19 +23,6 @@ def test_create(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.tenant.create, - "", - "valid-name", - ) - self.assertRaises( - AuthException, - client.mgmt.tenant.create, - "valid-key", - "", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( @@ -59,21 +45,6 @@ def test_update(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.tenant.update, - "", - "valid-id", - "valid-name", - ) - self.assertRaises( - AuthException, - client.mgmt.tenant.update, - "valid-key", - "", - "valid-name", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( @@ -95,19 +66,6 @@ def test_delete(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.tenant.delete, - "", - "valid-id", - ) - self.assertRaises( - AuthException, - client.mgmt.tenant.delete, - "valid-key", - "", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( diff --git a/tests/management/test_user.py b/tests/management/test_user.py index aa1edf72..29154cbc 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -1,10 +1,7 @@ -import json import unittest -from unittest import mock from unittest.mock import patch from descope import AuthException, DescopeClient -from descope.common import REFRESH_SESSION_COOKIE_NAME, LoginOptions class TestUser(unittest.TestCase): @@ -24,19 +21,6 @@ def test_create(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.user.create, - "", - "valid-identifier", - ) - self.assertRaises( - AuthException, - client.mgmt.user.create, - "valid-key", - "", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( @@ -57,19 +41,6 @@ def test_update(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.user.update, - "", - "valid-identifier", - ) - self.assertRaises( - AuthException, - client.mgmt.user.update, - "valid-key", - "", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( @@ -91,19 +62,6 @@ def test_delete(self): client = DescopeClient(self.dummy_project_id, self.public_key_dict) # Test failed flows - self.assertRaises( - AuthException, - client.mgmt.user.delete, - "", - "valid-id", - ) - self.assertRaises( - AuthException, - client.mgmt.user.delete, - "valid-key", - "", - ) - with patch("requests.post") as mock_post: mock_post.return_value.ok = False self.assertRaises( From 1085fde1e9b29067fa489c7cf6e90f94ddd54ecb Mon Sep 17 00:00:00 2001 From: itaihanski Date: Mon, 21 Nov 2022 17:18:07 +0200 Subject: [PATCH 04/10] Fix phrasing --- descope/authmethod/otp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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: From ba27045a4dd4b404c819bd4c3a0b8d484ccebd34 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Tue, 22 Nov 2022 09:41:51 +0200 Subject: [PATCH 05/10] Fix documentation --- descope/management/tenant.py | 4 ++-- descope/management/user.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/descope/management/tenant.py b/descope/management/tenant.py index 04b0ceab..8157b523 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -50,8 +50,8 @@ def update( 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. + id (str): The ID of the tenant to update. + name (str): Updated tenant's 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. diff --git a/descope/management/user.py b/descope/management/user.py index ecfa37d1..0e010c05 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -59,12 +59,12 @@ def update( user_tenants: List[UserTenants] = None ): """ - Update an existing user with the given name and domains. IMPORTANT: All parameters are used as overrides + Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides to the existing user. Use carefully. Args: mgmt_key (str): A management key generated in the Descope console. All management functions require it. - identifier (str): user identifier. + 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. From 02c76f3454db27fa6a0e64f71a3d486c050ff81b Mon Sep 17 00:00:00 2001 From: itaihanski Date: Tue, 22 Nov 2022 09:52:03 +0200 Subject: [PATCH 06/10] Reformat using black --- descope/management/sso_settings.py | 20 ++++++++++++-------- descope/management/tenant.py | 19 +++++++++++-------- descope/management/user.py | 28 ++++++++++++++++------------ tests/management/test_tenant.py | 4 +--- tests/management/test_user.py | 11 +++++++---- 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 6f96fa9f..1b10941a 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -5,12 +5,11 @@ class RoleMapping: - def __init__( - self, groups: List[str], role_name: str - ): + def __init__(self, groups: List[str], role_name: str): self.groups = groups self.role_name = role_name + class SSOSettings: _auth: Auth @@ -44,7 +43,9 @@ def configure( """ self._auth.do_post( MgmtV1.ssoConfigurePath, - _compose_configure_body(tenant_id, enabled, idp_url, entity_id, idp_cert, redirect_url), + _compose_configure_body( + tenant_id, enabled, idp_url, entity_id, idp_cert, redirect_url + ), pswd=mgmt_key, ) @@ -72,7 +73,7 @@ def configure_via_metadata( _compose_metadata_body(tenant_id, enabled, idp_metadata_url), pswd=mgmt_key, ) - + def map_roles( self, mgmt_key: str, @@ -96,13 +97,13 @@ def map_roles( pswd=mgmt_key, ) + class UserTenants: - def __init__( - self, tenant_id: str, role_names: List[str] - ): + def __init__(self, tenant_id: str, role_names: List[str]): self.tenant_id = tenant_id self.role_names = role_names + @staticmethod def _compose_configure_body( tenant_id: str, @@ -121,6 +122,7 @@ def _compose_configure_body( "redirectURL": redirect_url, } + @staticmethod def _compose_metadata_body( tenant_id: str, @@ -133,6 +135,7 @@ def _compose_metadata_body( "idpMetadataURL": idp_metadata_url, } + @staticmethod def _compose_role_mapping_body( tenant_id: str, @@ -143,6 +146,7 @@ def _compose_role_mapping_body( "roleMapping": _role_mapping_to_dict(role_mapping), } + @staticmethod def _role_mapping_to_dict(role_mapping: List[RoleMapping]) -> list: role_mapping_list = [] diff --git a/descope/management/tenant.py b/descope/management/tenant.py index 8157b523..da8b292b 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -33,7 +33,9 @@ def create( """ uri = MgmtV1.tenantCreatePath response = self._auth.do_post( - uri, _compose_create_update_body(name, id, self_provisioning_domains), pswd=mgmt_key + uri, + _compose_create_update_body(name, id, self_provisioning_domains), + pswd=mgmt_key, ) return response.json() @@ -60,9 +62,11 @@ def update( """ uri = MgmtV1.tenantUpdatePath self._auth.do_post( - uri, _compose_create_update_body(name, id, self_provisioning_domains), pswd=mgmt_key + uri, + _compose_create_update_body(name, id, self_provisioning_domains), + pswd=mgmt_key, ) - + def delete( self, mgmt_key: str, @@ -79,10 +83,9 @@ def delete( AuthException: raised if creation operation fails """ uri = MgmtV1.tenantDeletePath - self._auth.do_post( - uri, {"id": id}, pswd=mgmt_key - ) - + 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] @@ -90,5 +93,5 @@ def _compose_create_update_body( return { "name": name, "id": id, - "selfProvisioningDomains": self_provisioning_domains + "selfProvisioningDomains": self_provisioning_domains, } diff --git a/descope/management/user.py b/descope/management/user.py index 0e010c05..dabad998 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -5,12 +5,11 @@ class UserTenants: - def __init__( - self, tenant_id: str, role_names: List[str] - ): + def __init__(self, tenant_id: str, role_names: List[str]): self.tenant_id = tenant_id self.role_names = role_names + class User: _auth: Auth @@ -25,7 +24,7 @@ def create( phone_number: str = None, display_name: str = None, role_names: List[str] = None, - user_tenants: List[UserTenants] = None + user_tenants: List[UserTenants] = None, ) -> dict: """ Create a new user. User's can have any number of optional fields, including email, phone number and authorization. @@ -44,7 +43,9 @@ def create( """ self._auth.do_post( MgmtV1.userCreatePath, - _compose_create_update_body(identifier, email, phone_number, display_name, role_names, user_tenants), + _compose_create_update_body( + identifier, email, phone_number, display_name, role_names, user_tenants + ), pswd=mgmt_key, ) @@ -56,7 +57,7 @@ def update( phone_number: str = None, display_name: str = None, role_names: List[str] = None, - user_tenants: List[UserTenants] = None + user_tenants: List[UserTenants] = None, ): """ Update an existing user with the given various fields. IMPORTANT: All parameters are used as overrides @@ -76,10 +77,12 @@ def update( """ self._auth.do_post( MgmtV1.userUpdatePath, - _compose_create_update_body(identifier, email, phone_number, display_name, role_names, user_tenants), + _compose_create_update_body( + identifier, email, phone_number, display_name, role_names, user_tenants + ), pswd=mgmt_key, ) - + def delete( self, mgmt_key: str, @@ -101,13 +104,13 @@ def delete( pswd=mgmt_key, ) + class UserTenants: - def __init__( - self, tenant_id: str, role_names: List[str] - ): + def __init__(self, tenant_id: str, role_names: List[str]): self.tenant_id = tenant_id self.role_names = role_names + @staticmethod def _compose_create_update_body( identifier: str, @@ -123,9 +126,10 @@ def _compose_create_update_body( "phoneNumber": phone_number, "displayName": display_name, "roleNames": role_names, - "userTenants": _user_tenants_to_dict(user_tenants) + "userTenants": _user_tenants_to_dict(user_tenants), } + @staticmethod def _user_tenants_to_dict(user_tenants: List[UserTenants]) -> list: if not user_tenants: diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 399f48a7..b1a17e00 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -78,6 +78,4 @@ def test_delete(self): # Test success flow with patch("requests.post") as mock_post: mock_post.return_value.ok = True - self.assertIsNone( - client.mgmt.tenant.delete("key", "t1") - ) + self.assertIsNone(client.mgmt.tenant.delete("key", "t1")) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index 29154cbc..a69f79b6 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -55,7 +55,12 @@ def test_update(self): 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"]) + client.mgmt.user.update( + "key", + "identifier", + display_name="new-name", + role_names=["domain.com"], + ) ) def test_delete(self): @@ -74,6 +79,4 @@ def test_delete(self): # Test success flow with patch("requests.post") as mock_post: mock_post.return_value.ok = True - self.assertIsNone( - client.mgmt.user.delete("key", "t1") - ) + self.assertIsNone(client.mgmt.user.delete("key", "t1")) From 15a433d50540446b8255aed2d41049552c30f7c1 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Tue, 22 Nov 2022 10:15:03 +0200 Subject: [PATCH 07/10] Sort imports with isort --- descope/__init__.py | 2 +- descope/descope_client.py | 2 +- descope/mgmt.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/descope/__init__.py b/descope/__init__.py index 66790175..d7d4a8df 100644 --- a/descope/__init__.py +++ b/descope/__init__.py @@ -8,5 +8,5 @@ ) from descope.descope_client import DescopeClient from descope.exceptions import AuthException -from descope.management.user import UserTenants from descope.management.sso_settings import RoleMapping +from descope.management.user import UserTenants diff --git a/descope/descope_client.py b/descope/descope_client.py index 3fda189b..67c4089f 100644 --- a/descope/descope_client.py +++ b/descope/descope_client.py @@ -3,7 +3,6 @@ import requests from descope.auth import Auth # noqa: F401 -from descope.mgmt import MGMT # noqa: F401 from descope.authmethod.magiclink import MagicLink # noqa: F401 from descope.authmethod.oauth import OAuth # noqa: F401 from descope.authmethod.otp import OTP # noqa: F401 @@ -12,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: diff --git a/descope/mgmt.py b/descope/mgmt.py index f625aa3a..6aba5215 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -1,7 +1,7 @@ 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 -from descope.management.sso_settings import SSOSettings # noqa: F401 class MGMT: From 70c31f1a9830822c545051a2ae6439941fa56f4e Mon Sep 17 00:00:00 2001 From: itaihanski Date: Tue, 22 Nov 2022 11:26:08 +0200 Subject: [PATCH 08/10] Fix static methods --- descope/management/sso_settings.py | 114 ++++++++++++++--------------- descope/management/tenant.py | 23 +++--- descope/management/user.py | 72 +++++++++--------- 3 files changed, 101 insertions(+), 108 deletions(-) diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 1b10941a..5e639182 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -43,7 +43,7 @@ def configure( """ self._auth.do_post( MgmtV1.ssoConfigurePath, - _compose_configure_body( + SSOSettings._compose_configure_body( tenant_id, enabled, idp_url, entity_id, idp_cert, redirect_url ), pswd=mgmt_key, @@ -70,7 +70,7 @@ def configure_via_metadata( """ self._auth.do_post( MgmtV1.ssoMetadataPath, - _compose_metadata_body(tenant_id, enabled, idp_metadata_url), + SSOSettings._compose_metadata_body(tenant_id, enabled, idp_metadata_url), pswd=mgmt_key, ) @@ -93,68 +93,64 @@ def map_roles( """ self._auth.do_post( MgmtV1.ssoRoleMappingPath, - _compose_role_mapping_body(tenant_id, role_mapping), + SSOSettings._compose_role_mapping_body(tenant_id, role_mapping), pswd=mgmt_key, ) + @staticmethod + def _compose_configure_body( + tenant_id: str, + enabled: bool, + idp_url: str, + entity_id: str, + idp_cert: str, + redirect_url: str, + ) -> dict: + return { + "tenantId": tenant_id, + "enabled": enabled, + "idpURL": idp_url, + "entityId": entity_id, + "idpCert": idp_cert, + "redirectURL": redirect_url, + } + + @staticmethod + def _compose_metadata_body( + tenant_id: str, + enabled: bool, + idp_metadata_url: str, + ) -> dict: + return { + "tenantId": tenant_id, + "enabled": enabled, + "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 + class UserTenants: def __init__(self, tenant_id: str, role_names: List[str]): self.tenant_id = tenant_id self.role_names = role_names - - -@staticmethod -def _compose_configure_body( - tenant_id: str, - enabled: bool, - idp_url: str, - entity_id: str, - idp_cert: str, - redirect_url: str, -) -> dict: - return { - "tenantId": tenant_id, - "enabled": enabled, - "idpURL": idp_url, - "entityId": entity_id, - "idpCert": idp_cert, - "redirectURL": redirect_url, - } - - -@staticmethod -def _compose_metadata_body( - tenant_id: str, - enabled: bool, - idp_metadata_url: str, -) -> dict: - return { - "tenantId": tenant_id, - "enabled": enabled, - "idpMetadataURL": idp_metadata_url, - } - - -@staticmethod -def _compose_role_mapping_body( - tenant_id: str, - role_mapping: List[RoleMapping], -) -> dict: - return { - "tenantId": tenant_id, - "roleMapping": _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 index da8b292b..14e85b73 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -34,7 +34,7 @@ def create( uri = MgmtV1.tenantCreatePath response = self._auth.do_post( uri, - _compose_create_update_body(name, id, self_provisioning_domains), + Tenant._compose_create_update_body(name, id, self_provisioning_domains), pswd=mgmt_key, ) return response.json() @@ -63,7 +63,7 @@ def update( uri = MgmtV1.tenantUpdatePath self._auth.do_post( uri, - _compose_create_update_body(name, id, self_provisioning_domains), + Tenant._compose_create_update_body(name, id, self_provisioning_domains), pswd=mgmt_key, ) @@ -85,13 +85,12 @@ def delete( 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, - } + @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 index dabad998..64e9df3c 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -43,7 +43,7 @@ def create( """ self._auth.do_post( MgmtV1.userCreatePath, - _compose_create_update_body( + User._compose_create_update_body( identifier, email, phone_number, display_name, role_names, user_tenants ), pswd=mgmt_key, @@ -77,7 +77,7 @@ def update( """ self._auth.do_post( MgmtV1.userUpdatePath, - _compose_create_update_body( + User._compose_create_update_body( identifier, email, phone_number, display_name, role_names, user_tenants ), pswd=mgmt_key, @@ -104,43 +104,41 @@ def delete( 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: + if not user_tenants: + return None + + user_tenant_list = [] + for user_tenant in user_tenants: + user_tenant_list.append( + { + "tenantId": user_tenant.tenant_id, + "roleNames": user_tenant.role_names, + } + ) + return user_tenant_list + class UserTenants: def __init__(self, tenant_id: str, role_names: List[str]): self.tenant_id = tenant_id self.role_names = role_names - - -@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_tenants_to_dict(user_tenants), - } - - -@staticmethod -def _user_tenants_to_dict(user_tenants: List[UserTenants]) -> list: - if not user_tenants: - return None - - user_tenant_list = [] - for user_tenant in user_tenants: - user_tenant_list.append( - { - "tenantId": user_tenant.tenant_id, - "roleNames": user_tenant.role_names, - } - ) - return user_tenant_list From 0de76cc189349806529e10cc983ccd98fd680cfa Mon Sep 17 00:00:00 2001 From: itaihanski Date: Tue, 22 Nov 2022 21:30:59 +0200 Subject: [PATCH 09/10] Fix documentation --- descope/management/sso_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 5e639182..5586a2b5 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -32,7 +32,7 @@ def configure( 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 (str): Is SSO enabled + enabled (bool): Is SSO enabled 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. @@ -62,7 +62,7 @@ def configure_via_metadata( 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 (str): Is SSO enabled + enabled (bool): Is SSO enabled idp_metadata_url (str): The URL to fetch SSO settings from. Raise: From ba2c40b95717acf4e23128ce70e2dbbeac950a68 Mon Sep 17 00:00:00 2001 From: itaihanski Date: Thu, 24 Nov 2022 15:58:04 +0200 Subject: [PATCH 10/10] Fix PR comments, remove unneeded fields, fix naming and documentation --- descope/management/sso_settings.py | 31 ++++-------- descope/management/tenant.py | 10 ++-- descope/management/user.py | 50 +++++++++---------- samples/management_sso_sample_app.py | 65 +++++++++++++++++++++++++ samples/management_tenant_sample_app.py | 52 ++++++++++++++++++++ samples/management_user_sample_app.py | 50 +++++++++++++++++++ tests/management/test_sso_settings.py | 17 +++++-- tests/management/test_user.py | 13 ++++- 8 files changed, 228 insertions(+), 60 deletions(-) create mode 100644 samples/management_sso_sample_app.py create mode 100644 samples/management_tenant_sample_app.py create mode 100644 samples/management_user_sample_app.py diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 5586a2b5..867b160b 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -20,23 +20,21 @@ def configure( self, mgmt_key: str, tenant_id: str, - enabled: bool, idp_url: str, entity_id: str, idp_cert: str, - redirect_url: str, - ) -> dict: + 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 - enabled (bool): Is SSO enabled 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): Redirect URL after successful authentication. + redirect_url (str): An Optional Redirect URL after successful authentication. Raise: AuthException: raised if configuration operation fails @@ -44,7 +42,7 @@ def configure( self._auth.do_post( MgmtV1.ssoConfigurePath, SSOSettings._compose_configure_body( - tenant_id, enabled, idp_url, entity_id, idp_cert, redirect_url + tenant_id, idp_url, entity_id, idp_cert, redirect_url ), pswd=mgmt_key, ) @@ -53,7 +51,6 @@ def configure_via_metadata( self, mgmt_key: str, tenant_id: str, - enabled: bool, idp_metadata_url: str, ): """ @@ -70,7 +67,7 @@ def configure_via_metadata( """ self._auth.do_post( MgmtV1.ssoMetadataPath, - SSOSettings._compose_metadata_body(tenant_id, enabled, idp_metadata_url), + SSOSettings._compose_metadata_body(tenant_id, idp_metadata_url), pswd=mgmt_key, ) @@ -78,7 +75,7 @@ def map_roles( self, mgmt_key: str, tenant_id: str, - role_mapping: List[RoleMapping], + role_mappings: List[RoleMapping], ): """ Configure SSO role mapping from the IDP groups to the Descope roles. @@ -86,29 +83,27 @@ def map_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_mapping (List[RoleMapping]): A mapping between IDP groups and Descope roles. + 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_mapping), + SSOSettings._compose_role_mapping_body(tenant_id, role_mappings), pswd=mgmt_key, ) @staticmethod def _compose_configure_body( tenant_id: str, - enabled: bool, idp_url: str, entity_id: str, idp_cert: str, - redirect_url: str, + redirect_url: str = None, ) -> dict: return { "tenantId": tenant_id, - "enabled": enabled, "idpURL": idp_url, "entityId": entity_id, "idpCert": idp_cert, @@ -118,12 +113,10 @@ def _compose_configure_body( @staticmethod def _compose_metadata_body( tenant_id: str, - enabled: bool, idp_metadata_url: str, ) -> dict: return { "tenantId": tenant_id, - "enabled": enabled, "idpMetadataURL": idp_metadata_url, } @@ -148,9 +141,3 @@ def _role_mapping_to_dict(role_mapping: List[RoleMapping]) -> list: } ) return role_mapping_list - - -class UserTenants: - def __init__(self, tenant_id: str, role_names: List[str]): - self.tenant_id = tenant_id - self.role_names = role_names diff --git a/descope/management/tenant.py b/descope/management/tenant.py index 14e85b73..4e7730cc 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -15,7 +15,7 @@ def create( mgmt_key: str, name: str, id: str = None, - self_provisioning_domains: List[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 @@ -43,17 +43,17 @@ def update( self, mgmt_key: str, id: str, - name: str = None, - self_provisioning_domains: List[str] = None, + 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. Use carefully. + 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's name + 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. diff --git a/descope/management/user.py b/descope/management/user.py index 64e9df3c..7dfba7da 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -5,7 +5,7 @@ class UserTenants: - def __init__(self, tenant_id: str, role_names: List[str]): + def __init__(self, tenant_id: str, role_names: List[str] = []): self.tenant_id = tenant_id self.role_names = role_names @@ -23,11 +23,11 @@ def create( email: str = None, phone_number: str = None, display_name: str = None, - role_names: List[str] = None, - user_tenants: List[UserTenants] = None, + role_names: List[str] = [], + user_tenants: List[UserTenants] = [], ) -> dict: """ - Create a new user. User's can have any number of optional fields, including email, phone number and authorization. + 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. @@ -35,8 +35,10 @@ def create( 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. - user_tenants (List[str]): An optional list of the user's roles per tenant. + 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 @@ -56,12 +58,12 @@ def update( email: str = None, phone_number: str = None, display_name: str = None, - role_names: List[str] = None, - user_tenants: List[UserTenants] = 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. Use carefully. + 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. @@ -69,8 +71,10 @@ def 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. - user_tenants (List[str]): An optional list of the user's roles per tenant. + 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 @@ -124,21 +128,13 @@ def _compose_create_update_body( @staticmethod def _user_tenants_to_dict(user_tenants: List[UserTenants]) -> list: - if not user_tenants: - return None - user_tenant_list = [] - for user_tenant in user_tenants: - user_tenant_list.append( - { - "tenantId": user_tenant.tenant_id, - "roleNames": user_tenant.role_names, - } - ) + 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 - - -class UserTenants: - def __init__(self, tenant_id: str, role_names: List[str]): - self.tenant_id = tenant_id - self.role_names = role_names 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 index 133f3608..5be41d1d 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -28,7 +28,6 @@ def test_configure(self): client.mgmt.sso.configure, "valid-key", "tenant-id", - True, "https://idp.com", "entity-id", "cert", @@ -42,7 +41,6 @@ def test_configure(self): client.mgmt.sso.configure( "valid-key", "tenant-id", - True, "https://idp.com", "entity-id", "cert", @@ -50,6 +48,19 @@ def test_configure(self): ) ) + # 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) @@ -61,7 +72,6 @@ def test_configure_via_metadata(self): client.mgmt.sso.configure_via_metadata, "valid-key", "tenant-id", - True, "https://idp-meta.com", ) @@ -72,7 +82,6 @@ def test_configure_via_metadata(self): client.mgmt.sso.configure_via_metadata( "valid-key", "tenant-id", - True, "https://idp-meta.com", ) ) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index a69f79b6..af9cffc8 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import patch -from descope import AuthException, DescopeClient +from descope import AuthException, DescopeClient, UserTenants class TestUser(unittest.TestCase): @@ -34,7 +34,16 @@ def test_create(self): with patch("requests.post") as mock_post: mock_post.return_value.ok = True self.assertIsNone( - client.mgmt.user.create("key", "identifier", display_name="name") + 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):