diff --git a/README.md b/README.md index 1e949373c..99d5ab5d3 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,12 @@ descope_client.mgmt.tenant.update( custom_attributes={"attribute-name": "value"}, ) +# Patch only updates the fields you pass in - everything else on the tenant is left untouched. +descope_client.mgmt.tenant.patch_tenant( + id="my-custom-id", + disabled=True, +) + # Managing the tenant's settings # Getting the settings descope_client.mgmt.tenant.load_settings(id="my-custom-id") diff --git a/descope/management/_tenant_base.py b/descope/management/_tenant_base.py index c3cb2e4d8..340b7e68a 100644 --- a/descope/management/_tenant_base.py +++ b/descope/management/_tenant_base.py @@ -29,3 +29,34 @@ def _compose_create_update_body( if federated_app_ids is not None: body["federatedAppIds"] = federated_app_ids return body + + @staticmethod + def _compose_patch_body( + id: str, + name: Optional[str] = None, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + disabled: Optional[bool] = None, + enforce_sso: Optional[bool] = None, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + role_inheritance: Optional[str] = None, + ) -> dict: + body: dict[str, Any] = {"id": id} + if name is not None: + body["name"] = name + if self_provisioning_domains is not None: + body["selfProvisioningDomains"] = self_provisioning_domains + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + if disabled is not None: + body["disabled"] = disabled + if enforce_sso is not None: + body["enforceSSO"] = enforce_sso + if enforce_sso_exclusions is not None: + body["enforceSSOExclusions"] = enforce_sso_exclusions + if federated_app_ids is not None: + body["federatedAppIds"] = federated_app_ids + if role_inheritance is not None: + body["roleInheritance"] = role_inheritance + return body diff --git a/descope/management/common.py b/descope/management/common.py index bc7ba154f..5cb9b8c3b 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -127,6 +127,7 @@ class MgmtV1: # tenant tenant_create_path = "/v1/mgmt/tenant/create" tenant_update_path = "/v1/mgmt/tenant/update" + tenant_patch_path = "/v1/mgmt/tenant/patch" tenant_delete_path = "/v1/mgmt/tenant/delete" tenant_load_path = "/v1/mgmt/tenant" tenant_settings_path = "/v1/mgmt/tenant/settings" diff --git a/descope/management/tenant.py b/descope/management/tenant.py index 8eb405c41..bb1f2f8e1 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -106,6 +106,51 @@ def update( ), ) + def patch_tenant( + self, + id: str, + name: Optional[str] = None, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + disabled: Optional[bool] = None, + enforce_sso: Optional[bool] = None, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + role_inheritance: Optional[str] = None, + ) -> None: + """ + Patch an existing tenant. Only the given fields will be updated; omitted fields are left unchanged. + + Args: + id (str): The ID of the tenant to patch. + name (str): Optional updated tenant name. + self_provisioning_domains (List[str]): Optional list of domains associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + disabled (bool): Optional, login to the tenant will be disabled + enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso + enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement + federated_app_ids (List[str]): Optional, list of federated application IDs + role_inheritance (str): Optional, role inheritance setting for the tenant + + Raise: + AuthException: raised if patch operation fails + """ + self._http.patch( + MgmtV1.tenant_patch_path, + body=TenantBase._compose_patch_body( + id, + name, + self_provisioning_domains, + custom_attributes, + disabled, + enforce_sso, + enforce_sso_exclusions, + federated_app_ids, + role_inheritance, + ), + ) + def update_settings( self, id: str, diff --git a/descope/management/tenant_async.py b/descope/management/tenant_async.py index 282f91cee..5817332c5 100644 --- a/descope/management/tenant_async.py +++ b/descope/management/tenant_async.py @@ -110,6 +110,51 @@ async def update( ), ) + async def patch_tenant( + self, + id: str, + name: Optional[str] = None, + self_provisioning_domains: Optional[List[str]] = None, + custom_attributes: Optional[dict] = None, + disabled: Optional[bool] = None, + enforce_sso: Optional[bool] = None, + enforce_sso_exclusions: Optional[List[str]] = None, + federated_app_ids: Optional[List[str]] = None, + role_inheritance: Optional[str] = None, + ) -> None: + """ + Patch an existing tenant. Only the given fields will be updated; omitted fields are left unchanged. + + Args: + id (str): The ID of the tenant to patch. + name (str): Optional updated tenant name. + self_provisioning_domains (List[str]): Optional list of domains associated with this tenant. + Users authenticating from these domains will be associated with this tenant. + custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app + disabled (bool): Optional, login to the tenant will be disabled + enforce_sso (bool): Optional, login to the tenant is possible only using the configured sso + enforce_sso_exclusions (List[str]): Optional, list of user IDs excluded from SSO enforcement + federated_app_ids (List[str]): Optional, list of federated application IDs + role_inheritance (str): Optional, role inheritance setting for the tenant + + Raise: + AuthException: raised if patch operation fails + """ + await self._http.patch( + MgmtV1.tenant_patch_path, + body=TenantBase._compose_patch_body( + id, + name, + self_provisioning_domains, + custom_attributes, + disabled, + enforce_sso, + enforce_sso_exclusions, + federated_app_ids, + role_inheritance, + ), + ) + async def update_settings( self, id: str, diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index c5ae862fa..c2b304ff0 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -144,6 +144,64 @@ async def test_update(self, client_factory): follow_redirects=False, ) + async def test_patch_tenant(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_patch(make_response(status=500)) as _: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.patch_tenant("valid-id")) + + # Test success flow with id only + with client.mock_mgmt_patch(make_response()) as mock_patch: + result = await client.invoke(client.mgmt.tenant.patch_tenant("t1")) + assert result is None + assert_http_called( + mock_patch, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_patch_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "t1"}, + follow_redirects=False, + ) + + # Test success flow with all fields + with client.mock_mgmt_patch(make_response()) as mock_patch: + result = await client.invoke( + client.mgmt.tenant.patch_tenant( + "t1", + name="new-name", + self_provisioning_domains=["domain.com"], + custom_attributes={"k1": "v1"}, + disabled=True, + enforce_sso=True, + enforce_sso_exclusions=["user1", "user2"], + federated_app_ids=["app1", "app2"], + role_inheritance="tenant2", + ) + ) + assert result is None + assert_http_called( + mock_patch, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_patch_path}", + headers=MGMT_HEADERS, + params=None, + json={ + "id": "t1", + "name": "new-name", + "selfProvisioningDomains": ["domain.com"], + "customAttributes": {"k1": "v1"}, + "disabled": True, + "enforceSSO": True, + "enforceSSOExclusions": ["user1", "user2"], + "federatedAppIds": ["app1", "app2"], + "roleInheritance": "tenant2", + }, + follow_redirects=False, + ) + async def test_delete(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key")