From 41797ef7248fd12c78a0e21668ec2d61b419ef6c Mon Sep 17 00:00:00 2001 From: Doron Sharon Date: Mon, 29 Jun 2026 13:06:20 +0000 Subject: [PATCH 1/3] feat(mgmt): add management endpoints for parity with go-sdk Adds the 62 management endpoints exposed by the backend that the go-sdk supports but the python-sdk was missing, across sync + async: - New modules: third_party_application, lists, password, analytics, scope_claim_mapping (wired into the MGMT/MGMTAsync facades) - access_key: rotate, activate/deactivate/delete batch - user: custom attributes (create/delete/load), delete batch, import, passkey delete + list, trusted devices list/remove, recovery email/phone - sso_settings: redirect, load_all, new settings - sso_application: wsfed create/update, rotate/get secret - tenant: revoke sso configuration link - project: delete, snapshot export/import/validate - fga: mappable resources/schema, schema dry-run - jwt: impersonate step-up - audit: create audit webhook Endpoint constants added to MgmtV1; all endpoints covered by tests. Co-Authored-By: Claude Opus 4.8 --- descope/management/_lists_base.py | 51 +++ descope/management/_sso_application_base.py | 35 ++ .../_third_party_application_base.py | 43 ++ descope/management/access_key.py | 83 ++++ descope/management/access_key_async.py | 83 ++++ descope/management/analytics.py | 81 ++++ descope/management/analytics_async.py | 83 ++++ descope/management/audit.py | 20 + descope/management/audit_async.py | 20 + descope/management/common.py | 90 ++++ descope/management/fga.py | 50 +++ descope/management/fga_async.py | 50 +++ descope/management/jwt.py | 41 ++ descope/management/jwt_async.py | 41 ++ descope/management/lists.py | 248 +++++++++++ descope/management/lists_async.py | 250 +++++++++++ descope/management/password.py | 63 +++ descope/management/password_async.py | 65 +++ descope/management/project.py | 98 ++++ descope/management/project_async.py | 98 ++++ descope/management/scope_claim_mapping.py | 59 +++ .../management/scope_claim_mapping_async.py | 61 +++ descope/management/sso_application.py | 181 ++++++++ descope/management/sso_application_async.py | 181 ++++++++ descope/management/sso_settings.py | 88 ++++ descope/management/sso_settings_async.py | 88 ++++ descope/management/tenant.py | 24 + descope/management/tenant_async.py | 24 + descope/management/third_party_application.py | 358 +++++++++++++++ .../third_party_application_async.py | 360 +++++++++++++++ descope/management/user.py | 285 ++++++++++++ descope/management/user_async.py | 285 ++++++++++++ descope/mgmt.py | 35 ++ descope/mgmt_async.py | 37 ++ tests/management/test_access_key.py | 113 +++++ tests/management/test_analytics.py | 127 ++++++ tests/management/test_audit.py | 35 ++ tests/management/test_fga.py | 70 +++ tests/management/test_jwt.py | 40 ++ tests/management/test_lists.py | 318 +++++++++++++ tests/management/test_password.py | 91 ++++ tests/management/test_project.py | 115 +++++ tests/management/test_scope_claim_mapping.py | 112 +++++ tests/management/test_sso_application.py | 164 +++++++ tests/management/test_sso_settings.py | 101 +++++ tests/management/test_tenant.py | 26 ++ .../test_third_party_application.py | 421 ++++++++++++++++++ tests/management/test_user.py | 312 +++++++++++++ 48 files changed, 5704 insertions(+) create mode 100644 descope/management/_lists_base.py create mode 100644 descope/management/_third_party_application_base.py create mode 100644 descope/management/analytics.py create mode 100644 descope/management/analytics_async.py create mode 100644 descope/management/lists.py create mode 100644 descope/management/lists_async.py create mode 100644 descope/management/password.py create mode 100644 descope/management/password_async.py create mode 100644 descope/management/scope_claim_mapping.py create mode 100644 descope/management/scope_claim_mapping_async.py create mode 100644 descope/management/third_party_application.py create mode 100644 descope/management/third_party_application_async.py create mode 100644 tests/management/test_analytics.py create mode 100644 tests/management/test_lists.py create mode 100644 tests/management/test_password.py create mode 100644 tests/management/test_scope_claim_mapping.py create mode 100644 tests/management/test_third_party_application.py diff --git a/descope/management/_lists_base.py b/descope/management/_lists_base.py new file mode 100644 index 000000000..35817bc79 --- /dev/null +++ b/descope/management/_lists_base.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, List, Optional + + +class ListsBase: + @staticmethod + def _compose_create_body(name: str, description: Optional[str], list_type: str, data: Any) -> dict: + body = {"name": name, "type": list_type} + if description is not None: + body["description"] = description + if data is not None: + body["data"] = data + return body + + @staticmethod + def _compose_update_body(id: str, name: str, description: Optional[str], list_type: str, data: Any) -> dict: + body = {"id": id, "name": name, "type": list_type} + if description is not None: + body["description"] = description + if data is not None: + body["data"] = data + return body + + @staticmethod + def _compose_delete_body(id: str) -> dict: + return {"id": id} + + @staticmethod + def _compose_import_body(lists: List[dict]) -> dict: + return {"lists": lists} + + @staticmethod + def _compose_ip_body(id: str, ips: List[str]) -> dict: + return {"id": id, "ips": ips} + + @staticmethod + def _compose_check_ip_body(id: str, ip: str) -> dict: + return {"id": id, "ip": ip} + + @staticmethod + def _compose_text_body(id: str, texts: List[str]) -> dict: + return {"id": id, "texts": texts} + + @staticmethod + def _compose_check_text_body(id: str, text: str) -> dict: + return {"id": id, "text": text} + + @staticmethod + def _compose_clear_body(id: str) -> dict: + return {"id": id} diff --git a/descope/management/_sso_application_base.py b/descope/management/_sso_application_base.py index 82a92f1c7..5835030d5 100644 --- a/descope/management/_sso_application_base.py +++ b/descope/management/_sso_application_base.py @@ -88,3 +88,38 @@ def _compose_create_update_saml_body( "defaultSignatureAlgorithm": default_signature_algorithm, } return body + + @staticmethod + def _compose_create_update_wsfed_body( + name: str, + login_page_url: str, + realm: str, + reply_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + reply_allowed_callbacks: Optional[List[str]] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + error_redirect_url: Optional[str] = None, + ) -> dict: + body: dict[str, Any] = { + "id": id, + "name": name, + "description": description, + "enabled": enabled, + "logo": logo, + "loginPageUrl": login_page_url, + "realm": realm, + "replyUrl": reply_url, + "replyAllowedCallbacks": reply_allowed_callbacks if reply_allowed_callbacks else [], + "attributeMapping": saml_idp_attribute_mapping_info_to_dict(attribute_mapping), + "groupsMapping": saml_idp_groups_mapping_info_to_dict(groups_mapping), + "forceAuthentication": force_authentication, + "logoutRedirectUrl": logout_redirect_url, + "errorRedirectUrl": error_redirect_url, + } + return body diff --git a/descope/management/_third_party_application_base.py b/descope/management/_third_party_application_base.py new file mode 100644 index 000000000..d573a2e6a --- /dev/null +++ b/descope/management/_third_party_application_base.py @@ -0,0 +1,43 @@ +from typing import Any, Dict, List, Optional + + +# This is not part of the public API but a code helper +def compose_create_update_body( + name: str, + login_page_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, +) -> dict: + body: Dict[str, Any] = { + "name": name, + "loginPageUrl": login_page_url, + } + if id is not None: + body["id"] = id + if description is not None: + body["description"] = description + if logo is not None: + body["logo"] = logo + if approved_callback_urls is not None: + body["approvedCallbackUrls"] = approved_callback_urls + if permissions_scopes is not None: + body["permissionsScopes"] = permissions_scopes + if attributes_scopes is not None: + body["attributesScopes"] = attributes_scopes + if jwt_bearer_settings is not None: + body["jwtBearerSettings"] = jwt_bearer_settings + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + if force_pkce is not None: + body["forcePkce"] = force_pkce + if default_audience is not None: + body["defaultAudience"] = default_audience + return body diff --git a/descope/management/access_key.py b/descope/management/access_key.py index d8428d6ad..429354283 100644 --- a/descope/management/access_key.py +++ b/descope/management/access_key.py @@ -225,3 +225,86 @@ def delete( MgmtV1.access_key_delete_path, body={"id": id}, ) + + def rotate( + self, + id: str, + ) -> dict: + """ + Rotate an existing access key. Regenerates the secret while preserving the same access key ID, + name, roles, tenants, expiry, and metadata. + + Args: + id (str): The id of the access key to be rotated. + + Return value (dict): + Return dict in the format + { + "key": {}, + "cleartext": {} + } + The cleartext is the new secret and is only visible once. Save it immediately. + The previous secret stops working as soon as this call returns. + + Raise: + AuthException: raised if rotation operation fails + """ + response = self._http.post( + MgmtV1.access_key_rotate_path, + body={"id": id}, + ) + return response.json() + + def activate_batch( + self, + ids: List[str], + ): + """ + Activate multiple existing access keys in a single request. + + Args: + ids (List[str]): The list of access key IDs to be activated. + + Raise: + AuthException: raised if batch activation operation fails + """ + self._http.post( + MgmtV1.access_key_activate_batch_path, + body={"ids": ids}, + ) + + def deactivate_batch( + self, + ids: List[str], + ): + """ + Deactivate multiple existing access keys in a single request. + + Args: + ids (List[str]): The list of access key IDs to be deactivated. + + Raise: + AuthException: raised if batch deactivation operation fails + """ + self._http.post( + MgmtV1.access_key_deactivate_batch_path, + body={"ids": ids}, + ) + + def delete_batch( + self, + ids: List[str], + ): + """ + Delete multiple existing access keys in a single request. IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): The list of access key IDs to be deleted. + + Raise: + AuthException: raised if batch deletion operation fails + """ + self._http.post( + MgmtV1.access_key_delete_batch_path, + body={"ids": ids}, + ) diff --git a/descope/management/access_key_async.py b/descope/management/access_key_async.py index 03698fcb6..e9123673d 100644 --- a/descope/management/access_key_async.py +++ b/descope/management/access_key_async.py @@ -229,3 +229,86 @@ async def delete( MgmtV1.access_key_delete_path, body={"id": id}, ) + + async def rotate( + self, + id: str, + ) -> dict: + """ + Rotate an existing access key. Regenerates the secret while preserving the same access key ID, + name, roles, tenants, expiry, and metadata. + + Args: + id (str): The id of the access key to be rotated. + + Return value (dict): + Return dict in the format + { + "key": {}, + "cleartext": {} + } + The cleartext is the new secret and is only visible once. Save it immediately. + The previous secret stops working as soon as this call returns. + + Raise: + AuthException: raised if rotation operation fails + """ + response = await self._http.post( + MgmtV1.access_key_rotate_path, + body={"id": id}, + ) + return response.json() + + async def activate_batch( + self, + ids: List[str], + ): + """ + Activate multiple existing access keys in a single request. + + Args: + ids (List[str]): The list of access key IDs to be activated. + + Raise: + AuthException: raised if batch activation operation fails + """ + await self._http.post( + MgmtV1.access_key_activate_batch_path, + body={"ids": ids}, + ) + + async def deactivate_batch( + self, + ids: List[str], + ): + """ + Deactivate multiple existing access keys in a single request. + + Args: + ids (List[str]): The list of access key IDs to be deactivated. + + Raise: + AuthException: raised if batch deactivation operation fails + """ + await self._http.post( + MgmtV1.access_key_deactivate_batch_path, + body={"ids": ids}, + ) + + async def delete_batch( + self, + ids: List[str], + ): + """ + Delete multiple existing access keys in a single request. IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): The list of access key IDs to be deleted. + + Raise: + AuthException: raised if batch deletion operation fails + """ + await self._http.post( + MgmtV1.access_key_delete_batch_path, + body={"ids": ids}, + ) diff --git a/descope/management/analytics.py b/descope/management/analytics.py new file mode 100644 index 000000000..4259aae63 --- /dev/null +++ b/descope/management/analytics.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from descope._http_base import HTTPBase +from descope.management.common import MgmtV1 + + +class Analytics(HTTPBase): + def search( + self, + actions: Optional[List[str]] = None, + excluded_actions: Optional[List[str]] = None, + from_ts: Optional[datetime] = None, + to_ts: Optional[datetime] = None, + devices: Optional[List[str]] = None, + methods: Optional[List[str]] = None, + geos: Optional[List[str]] = None, + tenants: Optional[List[str]] = None, + group_by_action: bool = False, + group_by_device: bool = False, + group_by_method: bool = False, + group_by_geo: bool = False, + group_by_tenant: bool = False, + group_by_referrer: bool = False, + group_by_created: Optional[str] = None, + ) -> dict: + """ + Search analytics records according to given filters. + + Args: + actions (List[str]): Optional list of actions to filter by. + excluded_actions (List[str]): Optional list of actions to exclude. + from_ts (datetime): Optional retrieve analytics newer than given time. Limited to no older than 12 months. + to_ts (datetime): Optional retrieve records older than given time. + devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown". + methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password". + geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. + tenants (List[str]): Optional list of tenants to filter by. + group_by_action (bool): Should we group summarized results by action. + group_by_device (bool): Should we group summarized results by device. + group_by_method (bool): Should we group summarized results by method. + group_by_geo (bool): Should we group summarized results by geo. + group_by_tenant (bool): Should we group summarized results by tenant. + group_by_referrer (bool): Should we group summarized results by referrer. + group_by_created (str): Optional how should we group the dates. Possible values are "h" for hour, "d" for day, "w" for week, "m" for month and "q" for quarter. + + Return value (dict): + Return dict in the format {"analytics": [...]} + "analytics" contains a list of analytic records matching the filters. + + Raise: + AuthException: raised if search operation fails + """ + body = { + "actions": actions or [], + "excludedActions": excluded_actions or [], + "devices": devices or [], + "methods": methods or [], + "geos": geos or [], + "tenants": tenants or [], + "groupByAction": group_by_action, + "groupByDevice": group_by_device, + "groupByMethod": group_by_method, + "groupByGeo": group_by_geo, + "groupByTenant": group_by_tenant, + "groupByReferrer": group_by_referrer, + } + if from_ts is not None: + body["from"] = int(from_ts.timestamp() * 1000) + if to_ts is not None: + body["to"] = int(to_ts.timestamp() * 1000) + if group_by_created is not None: + body["groupByCreated"] = group_by_created + + response = self._http.post( + MgmtV1.analytics_search_path, + body=body, + ) + return response.json() diff --git a/descope/management/analytics_async.py b/descope/management/analytics_async.py new file mode 100644 index 000000000..df3d6efe6 --- /dev/null +++ b/descope/management/analytics_async.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime +from typing import List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class AnalyticsAsync(AsyncHTTPBase): + """Async counterpart of Analytics — all HTTP calls are coroutines.""" + + async def search( + self, + actions: Optional[List[str]] = None, + excluded_actions: Optional[List[str]] = None, + from_ts: Optional[datetime] = None, + to_ts: Optional[datetime] = None, + devices: Optional[List[str]] = None, + methods: Optional[List[str]] = None, + geos: Optional[List[str]] = None, + tenants: Optional[List[str]] = None, + group_by_action: bool = False, + group_by_device: bool = False, + group_by_method: bool = False, + group_by_geo: bool = False, + group_by_tenant: bool = False, + group_by_referrer: bool = False, + group_by_created: Optional[str] = None, + ) -> dict: + """ + Search analytics records according to given filters. + + Args: + actions (List[str]): Optional list of actions to filter by. + excluded_actions (List[str]): Optional list of actions to exclude. + from_ts (datetime): Optional retrieve analytics newer than given time. Limited to no older than 12 months. + to_ts (datetime): Optional retrieve records older than given time. + devices (List[str]): Optional list of devices to filter by. Current devices supported are "Bot"/"Mobile"/"Desktop"/"Tablet"/"Unknown". + methods (List[str]): Optional list of methods to filter by. Current auth methods are "otp"/"totp"/"magiclink"/"oauth"/"saml"/"password". + geos (List[str]): Optional list of geos to filter by. Geo is currently country code like "US", "IL", etc. + tenants (List[str]): Optional list of tenants to filter by. + group_by_action (bool): Should we group summarized results by action. + group_by_device (bool): Should we group summarized results by device. + group_by_method (bool): Should we group summarized results by method. + group_by_geo (bool): Should we group summarized results by geo. + group_by_tenant (bool): Should we group summarized results by tenant. + group_by_referrer (bool): Should we group summarized results by referrer. + group_by_created (str): Optional how should we group the dates. Possible values are "h" for hour, "d" for day, "w" for week, "m" for month and "q" for quarter. + + Return value (dict): + Return dict in the format {"analytics": [...]} + "analytics" contains a list of analytic records matching the filters. + + Raise: + AuthException: raised if search operation fails + """ + body = { + "actions": actions or [], + "excludedActions": excluded_actions or [], + "devices": devices or [], + "methods": methods or [], + "geos": geos or [], + "tenants": tenants or [], + "groupByAction": group_by_action, + "groupByDevice": group_by_device, + "groupByMethod": group_by_method, + "groupByGeo": group_by_geo, + "groupByTenant": group_by_tenant, + "groupByReferrer": group_by_referrer, + } + if from_ts is not None: + body["from"] = int(from_ts.timestamp() * 1000) + if to_ts is not None: + body["to"] = int(to_ts.timestamp() * 1000) + if group_by_created is not None: + body["groupByCreated"] = group_by_created + + response = await self._http.post( + MgmtV1.analytics_search_path, + body=body, + ) + return response.json() diff --git a/descope/management/audit.py b/descope/management/audit.py index 152ce6646..7cfd02558 100644 --- a/descope/management/audit.py +++ b/descope/management/audit.py @@ -132,3 +132,23 @@ def create_event( body["data"] = data self._http.post(MgmtV1.audit_create_event, body=body) + + def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): + """ + Create an audit webhook to receive audit events. + + Args: + name (str): The webhook name. + url (str): Optional webhook URL. + headers (dict): Optional headers to include in webhook requests. + + Raise: + AuthException: raised if create operation fails + """ + body: dict[str, Any] = {"name": name} + if url is not None: + body["url"] = url + if headers is not None: + body["headers"] = headers + + self._http.post(MgmtV1.audit_webhook_set_path, body=body) diff --git a/descope/management/audit_async.py b/descope/management/audit_async.py index 03e6ec4dc..4d9611b66 100644 --- a/descope/management/audit_async.py +++ b/descope/management/audit_async.py @@ -134,3 +134,23 @@ async def create_event( body["data"] = data await self._http.post(MgmtV1.audit_create_event, body=body) + + async def create_audit_webhook(self, name: str, url: Optional[str] = None, headers: Optional[dict] = None): + """ + Create an audit webhook to receive audit events. + + Args: + name (str): The webhook name. + url (str): Optional webhook URL. + headers (dict): Optional headers to include in webhook requests. + + Raise: + AuthException: raised if create operation fails + """ + body: dict[str, Any] = {"name": name} + if url is not None: + body["url"] = url + if headers is not None: + body["headers"] = headers + + await self._http.post(MgmtV1.audit_webhook_set_path, body=body) diff --git a/descope/management/common.py b/descope/management/common.py index 07cc0b008..9c2df5ea5 100644 --- a/descope/management/common.py +++ b/descope/management/common.py @@ -300,6 +300,96 @@ class MgmtV1: # license license_get_path = "/v1/mgmt/license" + # access key (batch + rotate) + access_key_activate_batch_path = "/v1/mgmt/accesskey/activate/batch" + access_key_deactivate_batch_path = "/v1/mgmt/accesskey/deactivate/batch" + access_key_delete_batch_path = "/v1/mgmt/accesskey/delete/batch" + access_key_rotate_path = "/v1/mgmt/accesskey/rotate" + + # analytics + analytics_search_path = "/v1/mgmt/analytics/search" + + # connector / audit webhook + audit_webhook_set_path = "/v2/mgmt/connector/audit/web/set" + + # fga (mappable + dryrun) + fga_mappable_resources_path = "/v1/mgmt/fga/mappable/resources" + fga_mappable_schema_path = "/v1/mgmt/fga/mappable/schema" + fga_schema_dryrun_path = "/v1/mgmt/fga/schema/dryrun" + + # jwt impersonate step-up + impersonate_stepup_path = "/v1/mgmt/impersonate/stepup" + + # list (deny / allow lists) + list_path = "/v1/mgmt/list" + list_all_path = "/v1/mgmt/list/all" + list_clear_path = "/v1/mgmt/list/clear" + list_delete_path = "/v1/mgmt/list/delete" + list_import_path = "/v1/mgmt/list/import" + list_ip_add_path = "/v1/mgmt/list/ip/add" + list_ip_check_path = "/v1/mgmt/list/ip/check" + list_ip_remove_path = "/v1/mgmt/list/ip/remove" + list_name_path = "/v1/mgmt/list/name" + list_text_add_path = "/v1/mgmt/list/text/add" + list_text_check_path = "/v1/mgmt/list/text/check" + list_text_remove_path = "/v1/mgmt/list/text/remove" + list_update_path = "/v1/mgmt/list/update" + + # password settings + password_settings_path = "/v1/mgmt/password/settings" + + # project (delete + snapshot) + project_delete_path = "/v1/mgmt/project/delete" + project_snapshot_export_path = "/v1/mgmt/project/snapshot/export" + project_snapshot_import_path = "/v1/mgmt/project/snapshot/import" + project_snapshot_validate_path = "/v1/mgmt/project/snapshot/validate" + + # scope claim mapping + scope_claim_mapping_get_path = "/v1/mgmt/scopeClaimMapping/get" + scope_claim_mapping_set_path = "/v1/mgmt/scopeClaimMapping/set" + scope_claim_mapping_delete_path = "/v1/mgmt/scopeClaimMapping/delete" + + # sso application (rotate / secret / wsfed) + sso_application_rotate_path = "/v1/mgmt/sso/idp/app/rotate" + sso_application_secret_path = "/v1/mgmt/sso/idp/app/secret" + sso_application_wsfed_create_path = "/v1/mgmt/sso/idp/app/wsfed/create" + sso_application_wsfed_update_path = "/v1/mgmt/sso/idp/app/wsfed/update" + + # sso settings (redirect / all / new) + sso_redirect_path = "/v1/mgmt/sso/redirect" + sso_load_all_settings_path = "/v2/mgmt/sso/settings/all" + sso_new_settings_path = "/v1/mgmt/sso/settings/new" + + # tenant revoke sso config link + tenant_revoke_sso_configuration_link_path = "/v1/mgmt/tenant/adminlinks/sso/revoke" + + # third party application + thirdparty_application_create_path = "/v1/mgmt/thirdparty/app/create" + thirdparty_application_update_path = "/v1/mgmt/thirdparty/app/update" + thirdparty_application_patch_path = "/v1/mgmt/thirdparty/app/patch" + thirdparty_application_delete_path = "/v1/mgmt/thirdparty/app/delete" + thirdparty_application_delete_batch_path = "/v1/mgmt/thirdparty/app/delete/batch" + thirdparty_application_load_path = "/v1/mgmt/thirdparty/app/load" + thirdparty_application_load_all_path = "/v1/mgmt/thirdparty/apps/load" + thirdparty_application_rotate_path = "/v1/mgmt/thirdparty/app/rotate" + thirdparty_application_secret_path = "/v1/mgmt/thirdparty/app/secret" + thirdparty_consents_delete_path = "/v1/mgmt/thirdparty/consents/delete" + thirdparty_consents_delete_tenant_path = "/v1/mgmt/thirdparty/consents/delete/tenant" + thirdparty_consents_search_path = "/v1/mgmt/thirdparty/consents/search" + + # user (gaps) + user_create_custom_attribute_path = "/v1/mgmt/user/customattribute/create" + user_delete_custom_attribute_path = "/v1/mgmt/user/customattribute/delete" + user_load_custom_attributes_path = "/v1/mgmt/user/customattributes" + user_delete_batch_path = "/v1/mgmt/user/delete/batch" + user_import_path = "/v1/mgmt/user/import" + user_delete_passkey_path = "/v1/mgmt/user/passkey/delete" + user_list_passkeys_path = "/v1/mgmt/user/passkeys/list" + user_list_trusted_devices_path = "/v1/mgmt/user/trusteddevices/list" + user_remove_trusted_device_path = "/v1/mgmt/user/trusteddevices/remove" + user_update_recovery_email_path = "/v1/mgmt/user/update/recovery/email" + user_update_recovery_phone_path = "/v1/mgmt/user/update/recovery/phone" + class MgmtSignUpOptions: def __init__( diff --git a/descope/management/fga.py b/descope/management/fga.py index 3456a1d9b..1a5a67250 100644 --- a/descope/management/fga.py +++ b/descope/management/fga.py @@ -160,3 +160,53 @@ def save_resources_details(self, resources_details: List[dict]) -> None: MgmtV1.fga_resources_save, body={"resourcesDetails": resources_details}, ) + + def load_mappable_schema(self, tenant_id: str, resources_limit: Optional[int] = None) -> dict: + """ + Load the mappable schema for the given tenant. + Args: + tenant_id (str): The tenant ID for which to load the mappable schema. + resources_limit (int): Optional limit on the number of resources to include. + Returns: + dict: The mappable schema as returned by the server. + Raise: + AuthException: raised if loading the mappable schema fails. + """ + params = {"tenantId": tenant_id} + if resources_limit is not None: + params["resourcesLimit"] = str(resources_limit) + response = self._http.get(MgmtV1.fga_mappable_schema_path, params=params) + return response.json() + + def load_mappable_resources( + self, tenant_id: str, resources_queries: List[dict], resources_limit: Optional[int] = None + ) -> List[dict]: + """ + Search for mappable resources matching the given queries. + Args: + tenant_id (str): The tenant ID for which to search resources. + resources_queries (List[dict]): List of resource queries to search for. + resources_limit (int): Optional limit on the number of resources to return. + Returns: + List[dict]: List of mappable resources matching the queries. + Raise: + AuthException: raised if the search fails. + """ + body = {"tenantId": tenant_id, "resourcesQueries": resources_queries} + if resources_limit is not None: + body["resourcesLimit"] = str(resources_limit) + response = self._http.post(MgmtV1.fga_mappable_resources_path, body=body) + return response.json().get("mappableResources", []) + + def save_schema_dryrun(self, schema: str) -> dict: + """ + Perform a dry run of saving the schema to validate it without applying changes. + Args: + schema (str): the schema in the AuthZ 1.0 DSL. + Returns: + dict: The dry run response containing validation results. + Raise: + AuthException: raised if the dry run fails or schema is invalid. + """ + response = self._http.post(MgmtV1.fga_schema_dryrun_path, body={"dsl": schema}) + return response.json() diff --git a/descope/management/fga_async.py b/descope/management/fga_async.py index bfaa12272..cecaf3a86 100644 --- a/descope/management/fga_async.py +++ b/descope/management/fga_async.py @@ -164,3 +164,53 @@ async def save_resources_details(self, resources_details: List[dict]) -> None: MgmtV1.fga_resources_save, body={"resourcesDetails": resources_details}, ) + + async def load_mappable_schema(self, tenant_id: str, resources_limit: Optional[int] = None) -> dict: + """ + Load the mappable schema for the given tenant. + Args: + tenant_id (str): The tenant ID for which to load the mappable schema. + resources_limit (int): Optional limit on the number of resources to include. + Returns: + dict: The mappable schema as returned by the server. + Raise: + AuthException: raised if loading the mappable schema fails. + """ + params = {"tenantId": tenant_id} + if resources_limit is not None: + params["resourcesLimit"] = str(resources_limit) + response = await self._http.get(MgmtV1.fga_mappable_schema_path, params=params) + return response.json() + + async def load_mappable_resources( + self, tenant_id: str, resources_queries: List[dict], resources_limit: Optional[int] = None + ) -> List[dict]: + """ + Search for mappable resources matching the given queries. + Args: + tenant_id (str): The tenant ID for which to search resources. + resources_queries (List[dict]): List of resource queries to search for. + resources_limit (int): Optional limit on the number of resources to return. + Returns: + List[dict]: List of mappable resources matching the queries. + Raise: + AuthException: raised if the search fails. + """ + body = {"tenantId": tenant_id, "resourcesQueries": resources_queries} + if resources_limit is not None: + body["resourcesLimit"] = str(resources_limit) + response = await self._http.post(MgmtV1.fga_mappable_resources_path, body=body) + return response.json().get("mappableResources", []) + + async def save_schema_dryrun(self, schema: str) -> dict: + """ + Perform a dry run of saving the schema to validate it without applying changes. + Args: + schema (str): the schema in the AuthZ 1.0 DSL. + Returns: + dict: The dry run response containing validation results. + Raise: + AuthException: raised if the dry run fails or schema is invalid. + """ + response = await self._http.post(MgmtV1.fga_schema_dryrun_path, body={"dsl": schema}) + return response.json() diff --git a/descope/management/jwt.py b/descope/management/jwt.py index 46ed0fb7d..c0d9032b4 100644 --- a/descope/management/jwt.py +++ b/descope/management/jwt.py @@ -90,6 +90,47 @@ def impersonate( ) return response.json().get("jwt", "") + def impersonate_stepup( + self, + impersonator_id: str, + login_id: str, + validate_consent: bool, + custom_claims: Optional[dict] = None, + tenant_id: Optional[str] = None, + refresh_duration: Optional[int] = None, + ) -> str: + """ + Impersonate to another user with step-up authentication + + Args: + impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. + login_id (str): login id of the user whom to which to impersonate to. + validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. + customClaims dict: Custom claims to add to JWT + tenant_id (str): tenant id to set on DCT claim. + refresh_duration (int): duration in seconds for which the new JWT will be valid + + Return value (str): A JWT of the impersonated user with step-up + + Raise: + AuthException: raised if update failed + """ + self._validate_impersonator_id(impersonator_id) + self._validate_login_id(login_id) + response = self._http.post( + MgmtV1.impersonate_stepup_path, + body={ + "loginId": login_id, + "impersonatorId": impersonator_id, + "validateConsent": validate_consent, + "customClaims": custom_claims, + "selectedTenant": tenant_id, + "refreshDuration": refresh_duration, + }, + params=None, + ) + return response.json().get("jwt", "") + def stop_impersonation( self, jwt: str, diff --git a/descope/management/jwt_async.py b/descope/management/jwt_async.py index 6bc277812..c2bb29edc 100644 --- a/descope/management/jwt_async.py +++ b/descope/management/jwt_async.py @@ -93,6 +93,47 @@ async def impersonate( ) return response.json().get("jwt", "") + async def impersonate_stepup( + self, + impersonator_id: str, + login_id: str, + validate_consent: bool, + custom_claims: Optional[dict] = None, + tenant_id: Optional[str] = None, + refresh_duration: Optional[int] = None, + ) -> str: + """ + Impersonate to another user with step-up authentication + + Args: + impersonator_id (str): login id / user id of impersonator, must have "impersonation" permission. + login_id (str): login id of the user whom to which to impersonate to. + validate_consent (bool): Indicate whether to allow impersonation in any case or only if a consent to this operation was granted. + customClaims dict: Custom claims to add to JWT + tenant_id (str): tenant id to set on DCT claim. + refresh_duration (int): duration in seconds for which the new JWT will be valid + + Return value (str): A JWT of the impersonated user with step-up + + Raise: + AuthException: raised if update failed + """ + self._validate_impersonator_id(impersonator_id) + self._validate_login_id(login_id) + response = await self._http.post( + MgmtV1.impersonate_stepup_path, + body={ + "loginId": login_id, + "impersonatorId": impersonator_id, + "validateConsent": validate_consent, + "customClaims": custom_claims, + "selectedTenant": tenant_id, + "refreshDuration": refresh_duration, + }, + params=None, + ) + return response.json().get("jwt", "") + async def stop_impersonation( self, jwt: str, diff --git a/descope/management/lists.py b/descope/management/lists.py new file mode 100644 index 000000000..af6bc1fba --- /dev/null +++ b/descope/management/lists.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import HTTPBase +from descope.management._lists_base import ListsBase +from descope.management.common import MgmtV1 + + +class Lists(ListsBase, HTTPBase): + def create( + self, + name: str, + list_type: str, + description: Optional[str] = None, + data: Any = None, + ) -> dict: + """ + Create a new list with the given name and type. + + Args: + name (str): The list's name (required, must be unique). + list_type (str): The list type - "texts", "ips", or "json" (required). + description (str, optional): Optional list description. + data (Any, optional): The list data - format depends on type: + - For "texts" and "ips": list of strings + - For "json": dict + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if create operation fails + """ + response = self._http.post( + MgmtV1.list_path, + body=ListsBase._compose_create_body(name, description, list_type, data), + ) + return response.json() + + def update( + self, + id: str, + name: str, + list_type: str, + description: Optional[str] = None, + data: Any = None, + ) -> dict: + """ + Update an existing list. All parameters are required and will override + whatever value is currently set in the existing list. Use carefully. + + Args: + id (str): The ID of the list to update. + name (str): Updated list name. + list_type (str): The list type - "texts", "ips", or "json". + description (str, optional): Updated description. + data (Any, optional): Updated list data. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if update operation fails + """ + response = self._http.post( + MgmtV1.list_update_path, + body=ListsBase._compose_update_body(id, name, description, list_type, data), + ) + return response.json() + + def delete(self, id: str): + """ + Delete an existing list. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the list to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post(MgmtV1.list_delete_path, body=ListsBase._compose_delete_body(id)) + + def load(self, id: str) -> dict: + """ + Load a list by ID. + + Args: + id (str): The ID of the list to load. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if load operation fails + """ + response = self._http.get(MgmtV1.list_path, params={"id": id}) + return response.json() + + def load_by_name(self, name: str) -> dict: + """ + Load a list by name. + + Args: + name (str): The name of the list to load. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if load operation fails + """ + response = self._http.get(MgmtV1.list_name_path, params={"name": name}) + return response.json() + + def load_all(self) -> dict: + """ + Load all lists in the project. + + Return value (dict): + Return dict in the format {"lists": [{...}, ...]}. + + Raise: + AuthException: raised if load operation fails + """ + response = self._http.get(MgmtV1.list_all_path) + return response.json() + + def import_lists(self, lists: List[dict]): + """ + Import multiple lists into the project. This will create or update + lists based on their ID. + + Args: + lists (List[dict]): List of list objects to import. + + Raise: + AuthException: raised if import operation fails + """ + self._http.post(MgmtV1.list_import_path, body=ListsBase._compose_import_body(lists)) + + def add_ips(self, id: str, ips: List[str]): + """ + Add IP addresses to an IP list. The list must be of type "ips". + Duplicate IPs are automatically ignored. The order of existing IPs is + preserved and new IPs are appended. + + Args: + id (str): The ID of the IP list. + ips (List[str]): List of IP addresses to add. + + Raise: + AuthException: raised if operation fails + """ + self._http.post(MgmtV1.list_ip_add_path, body=ListsBase._compose_ip_body(id, ips)) + + def remove_ips(self, id: str, ips: List[str]): + """ + Remove IP addresses from an IP list. The list must be of type "ips". + Non-existent IPs are silently ignored. + + Args: + id (str): The ID of the IP list. + ips (List[str]): List of IP addresses to remove. + + Raise: + AuthException: raised if operation fails + """ + self._http.post(MgmtV1.list_ip_remove_path, body=ListsBase._compose_ip_body(id, ips)) + + def check_ip(self, id: str, ip: str) -> bool: + """ + Check if an IP address exists in an IP list. The list must be of type "ips". + + Args: + id (str): The ID of the IP list. + ip (str): The IP address to check. + + Return value (bool): + True if the IP exists in the list, False otherwise. + + Raise: + AuthException: raised if operation fails + """ + response = self._http.post(MgmtV1.list_ip_check_path, body=ListsBase._compose_check_ip_body(id, ip)) + result = response.json() + return result.get("exists", False) + + def add_texts(self, id: str, texts: List[str]): + """ + Add text items to a text list. The list must be of type "texts". + Duplicate texts are automatically ignored. The order of existing texts is + preserved and new texts are appended. + + Args: + id (str): The ID of the text list. + texts (List[str]): List of text items to add. + + Raise: + AuthException: raised if operation fails + """ + self._http.post(MgmtV1.list_text_add_path, body=ListsBase._compose_text_body(id, texts)) + + def remove_texts(self, id: str, texts: List[str]): + """ + Remove text items from a text list. The list must be of type "texts". + Non-existent texts are silently ignored. + + Args: + id (str): The ID of the text list. + texts (List[str]): List of text items to remove. + + Raise: + AuthException: raised if operation fails + """ + self._http.post(MgmtV1.list_text_remove_path, body=ListsBase._compose_text_body(id, texts)) + + def check_text(self, id: str, text: str) -> bool: + """ + Check if a text exists in a text list. The list must be of type "texts". + + Args: + id (str): The ID of the text list. + text (str): The text to check. + + Return value (bool): + True if the text exists in the list, False otherwise. + + Raise: + AuthException: raised if operation fails + """ + response = self._http.post(MgmtV1.list_text_check_path, body=ListsBase._compose_check_text_body(id, text)) + result = response.json() + return result.get("exists", False) + + def clear(self, id: str): + """ + Clear all data from a list. The list metadata (name, description, type) is + preserved. For "json" type lists, sets data to empty object. For "texts" and + "ips" type lists, sets data to empty array. + + Args: + id (str): The ID of the list to clear. + + Raise: + AuthException: raised if operation fails + """ + self._http.post(MgmtV1.list_clear_path, body=ListsBase._compose_clear_body(id)) diff --git a/descope/management/lists_async.py b/descope/management/lists_async.py new file mode 100644 index 000000000..a53fd50e8 --- /dev/null +++ b/descope/management/lists_async.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from typing import Any, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management._lists_base import ListsBase +from descope.management.common import MgmtV1 + + +class ListsAsync(ListsBase, AsyncHTTPBase): + """Async counterpart of Lists — all HTTP calls are coroutines.""" + + async def create( + self, + name: str, + list_type: str, + description: Optional[str] = None, + data: Any = None, + ) -> dict: + """ + Create a new list with the given name and type. + + Args: + name (str): The list's name (required, must be unique). + list_type (str): The list type - "texts", "ips", or "json" (required). + description (str, optional): Optional list description. + data (Any, optional): The list data - format depends on type: + - For "texts" and "ips": list of strings + - For "json": dict + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if create operation fails + """ + response = await self._http.post( + MgmtV1.list_path, + body=ListsBase._compose_create_body(name, description, list_type, data), + ) + return response.json() + + async def update( + self, + id: str, + name: str, + list_type: str, + description: Optional[str] = None, + data: Any = None, + ) -> dict: + """ + Update an existing list. All parameters are required and will override + whatever value is currently set in the existing list. Use carefully. + + Args: + id (str): The ID of the list to update. + name (str): Updated list name. + list_type (str): The list type - "texts", "ips", or "json". + description (str, optional): Updated description. + data (Any, optional): Updated list data. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if update operation fails + """ + response = await self._http.post( + MgmtV1.list_update_path, + body=ListsBase._compose_update_body(id, name, description, list_type, data), + ) + return response.json() + + async def delete(self, id: str): + """ + Delete an existing list. IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the list to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post(MgmtV1.list_delete_path, body=ListsBase._compose_delete_body(id)) + + async def load(self, id: str) -> dict: + """ + Load a list by ID. + + Args: + id (str): The ID of the list to load. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.list_path, params={"id": id}) + return response.json() + + async def load_by_name(self, name: str) -> dict: + """ + Load a list by name. + + Args: + name (str): The name of the list to load. + + Return value (dict): + Return dict in the format {"list": {...}}. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.list_name_path, params={"name": name}) + return response.json() + + async def load_all(self) -> dict: + """ + Load all lists in the project. + + Return value (dict): + Return dict in the format {"lists": [{...}, ...]}. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.list_all_path) + return response.json() + + async def import_lists(self, lists: List[dict]): + """ + Import multiple lists into the project. This will create or update + lists based on their ID. + + Args: + lists (List[dict]): List of list objects to import. + + Raise: + AuthException: raised if import operation fails + """ + await self._http.post(MgmtV1.list_import_path, body=ListsBase._compose_import_body(lists)) + + async def add_ips(self, id: str, ips: List[str]): + """ + Add IP addresses to an IP list. The list must be of type "ips". + Duplicate IPs are automatically ignored. The order of existing IPs is + preserved and new IPs are appended. + + Args: + id (str): The ID of the IP list. + ips (List[str]): List of IP addresses to add. + + Raise: + AuthException: raised if operation fails + """ + await self._http.post(MgmtV1.list_ip_add_path, body=ListsBase._compose_ip_body(id, ips)) + + async def remove_ips(self, id: str, ips: List[str]): + """ + Remove IP addresses from an IP list. The list must be of type "ips". + Non-existent IPs are silently ignored. + + Args: + id (str): The ID of the IP list. + ips (List[str]): List of IP addresses to remove. + + Raise: + AuthException: raised if operation fails + """ + await self._http.post(MgmtV1.list_ip_remove_path, body=ListsBase._compose_ip_body(id, ips)) + + async def check_ip(self, id: str, ip: str) -> bool: + """ + Check if an IP address exists in an IP list. The list must be of type "ips". + + Args: + id (str): The ID of the IP list. + ip (str): The IP address to check. + + Return value (bool): + True if the IP exists in the list, False otherwise. + + Raise: + AuthException: raised if operation fails + """ + response = await self._http.post(MgmtV1.list_ip_check_path, body=ListsBase._compose_check_ip_body(id, ip)) + result = response.json() + return result.get("exists", False) + + async def add_texts(self, id: str, texts: List[str]): + """ + Add text items to a text list. The list must be of type "texts". + Duplicate texts are automatically ignored. The order of existing texts is + preserved and new texts are appended. + + Args: + id (str): The ID of the text list. + texts (List[str]): List of text items to add. + + Raise: + AuthException: raised if operation fails + """ + await self._http.post(MgmtV1.list_text_add_path, body=ListsBase._compose_text_body(id, texts)) + + async def remove_texts(self, id: str, texts: List[str]): + """ + Remove text items from a text list. The list must be of type "texts". + Non-existent texts are silently ignored. + + Args: + id (str): The ID of the text list. + texts (List[str]): List of text items to remove. + + Raise: + AuthException: raised if operation fails + """ + await self._http.post(MgmtV1.list_text_remove_path, body=ListsBase._compose_text_body(id, texts)) + + async def check_text(self, id: str, text: str) -> bool: + """ + Check if a text exists in a text list. The list must be of type "texts". + + Args: + id (str): The ID of the text list. + text (str): The text to check. + + Return value (bool): + True if the text exists in the list, False otherwise. + + Raise: + AuthException: raised if operation fails + """ + response = await self._http.post(MgmtV1.list_text_check_path, body=ListsBase._compose_check_text_body(id, text)) + result = response.json() + return result.get("exists", False) + + async def clear(self, id: str): + """ + Clear all data from a list. The list metadata (name, description, type) is + preserved. For "json" type lists, sets data to empty object. For "texts" and + "ips" type lists, sets data to empty array. + + Args: + id (str): The ID of the list to clear. + + Raise: + AuthException: raised if operation fails + """ + await self._http.post(MgmtV1.list_clear_path, body=ListsBase._compose_clear_body(id)) diff --git a/descope/management/password.py b/descope/management/password.py new file mode 100644 index 000000000..0fbcdeb70 --- /dev/null +++ b/descope/management/password.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from descope._http_base import HTTPBase +from descope.management.common import MgmtV1 + + +class Password(HTTPBase): + def get_settings(self, tenant_id: str = "") -> dict: + """ + Get password settings for the project or a specific tenant. + + Args: + tenant_id (str): Optional tenant ID. If empty, returns project-level settings. + + Return value (dict): + Return dict in the format + { + "enabled": bool, + "minLength": int, + "lowercase": bool, + "uppercase": bool, + "number": bool, + "nonAlphanumeric": bool, + "expiration": bool, + "expirationWeeks": int, + "reuse": bool, + "reuseAmount": int, + "lock": bool, + "lockAttempts": int + } + + Raise: + AuthException: raised if get operation fails + """ + params = {"tenantId": tenant_id} + response = self._http.get(MgmtV1.password_settings_path, params=params) + return response.json() + + def configure_settings(self, tenant_id: str, settings: dict): + """ + Configure password settings for the project or a specific tenant. + + Args: + tenant_id (str): Tenant ID. Empty string for project-level settings. + settings (dict): Password settings dict with keys: + - enabled (bool): Whether password authentication is enabled + - minLength (int): Minimum password length + - lowercase (bool): Require lowercase characters + - uppercase (bool): Require uppercase characters + - number (bool): Require numeric characters + - nonAlphanumeric (bool): Require non-alphanumeric characters + - expiration (bool): Enable password expiration + - expirationWeeks (int): Number of weeks until password expires + - reuse (bool): Enable password reuse prevention + - reuseAmount (int): Number of previous passwords to prevent reuse + - lock (bool): Enable account locking after failed attempts + - lockAttempts (int): Number of failed attempts before locking + + Raise: + AuthException: raised if configure operation fails + """ + body = {"tenantId": tenant_id, **settings} + self._http.post(MgmtV1.password_settings_path, body=body) diff --git a/descope/management/password_async.py b/descope/management/password_async.py new file mode 100644 index 000000000..4e36f45f0 --- /dev/null +++ b/descope/management/password_async.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class PasswordAsync(AsyncHTTPBase): + """Async counterpart of Password — all HTTP calls are coroutines.""" + + async def get_settings(self, tenant_id: str = "") -> dict: + """ + Get password settings for the project or a specific tenant. + + Args: + tenant_id (str): Optional tenant ID. If empty, returns project-level settings. + + Return value (dict): + Return dict in the format + { + "enabled": bool, + "minLength": int, + "lowercase": bool, + "uppercase": bool, + "number": bool, + "nonAlphanumeric": bool, + "expiration": bool, + "expirationWeeks": int, + "reuse": bool, + "reuseAmount": int, + "lock": bool, + "lockAttempts": int + } + + Raise: + AuthException: raised if get operation fails + """ + params = {"tenantId": tenant_id} + response = await self._http.get(MgmtV1.password_settings_path, params=params) + return response.json() + + async def configure_settings(self, tenant_id: str, settings: dict): + """ + Configure password settings for the project or a specific tenant. + + Args: + tenant_id (str): Tenant ID. Empty string for project-level settings. + settings (dict): Password settings dict with keys: + - enabled (bool): Whether password authentication is enabled + - minLength (int): Minimum password length + - lowercase (bool): Require lowercase characters + - uppercase (bool): Require uppercase characters + - number (bool): Require numeric characters + - nonAlphanumeric (bool): Require non-alphanumeric characters + - expiration (bool): Enable password expiration + - expirationWeeks (int): Number of weeks until password expires + - reuse (bool): Enable password reuse prevention + - reuseAmount (int): Number of previous passwords to prevent reuse + - lock (bool): Enable account locking after failed attempts + - lockAttempts (int): Number of failed attempts before locking + + Raise: + AuthException: raised if configure operation fails + """ + body = {"tenantId": tenant_id, **settings} + await self._http.post(MgmtV1.password_settings_path, body=body) diff --git a/descope/management/project.py b/descope/management/project.py index 1202a721c..1057acec3 100644 --- a/descope/management/project.py +++ b/descope/management/project.py @@ -152,6 +152,104 @@ def import_project( ) return + def delete(self): + """ + Delete the current project. + IMPORTANT: This action is irreversible. Use carefully. + + Raise: + AuthException: raised if delete operation fails + """ + self._http.post( + MgmtV1.project_delete_path, + body={}, + ) + + def export_snapshot( + self, + format: Optional[str] = None, + ) -> dict: + """ + Exports a snapshot of all the settings and configurations for a project and returns + the raw JSON files response as a dictionary. + + Args: + format (str): Optional format for the snapshot export. + + Return value (dict): + Return dict containing the exported snapshot data. + + Raise: + AuthException: raised if export operation fails + """ + body = {} + if format: + body["format"] = format + response = self._http.post( + MgmtV1.project_snapshot_export_path, + body=body, + ) + return response.json() + + def import_snapshot( + self, + files: dict, + input_secrets: Optional[dict] = None, + excludes: Optional[List[str]] = None, + ): + """ + Imports a snapshot of all settings and configurations into a project, overriding any + current configuration. + + Args: + files (dict): The raw JSON dictionary of files, in the same format as the one + returned by calls to export_snapshot. + input_secrets (dict): Optional secrets that need to be provided for the import. + excludes (List[str]): Optional list of items to exclude from the import. + + Raise: + AuthException: raised if import operation fails + """ + body: dict = {"files": files} + if input_secrets is not None: + body["inputSecrets"] = input_secrets + if excludes is not None: + body["excludes"] = excludes + self._http.post( + MgmtV1.project_snapshot_import_path, + body=body, + ) + + def validate_snapshot( + self, + files: dict, + input_secrets: Optional[dict] = None, + ) -> dict: + """ + Validates a snapshot by performing an import dry run and reporting any validation + failures or missing data. This should be called right before import_snapshot to + minimize the risk of the import failing. + + Args: + files (dict): The raw JSON dictionary of files to validate. + input_secrets (dict): Optional secrets to provide for validation. + + Return value (dict): + Return dict containing validation results, including 'ok' boolean and any 'failures' + or 'missingSecrets' if validation fails. + + Raise: + AuthException: raised if validation operation fails + """ + body: dict = {"files": files} + if input_secrets is not None: + body["inputSecrets"] = input_secrets + response = self._http.post( + MgmtV1.project_snapshot_validate_path, + body=body, + ) + return response.json() + # Function to remove 'tag' field from each project def remove_tag_field(self, projects): return [{k: v for k, v in project.items() if k != "tag"} for project in projects] diff --git a/descope/management/project_async.py b/descope/management/project_async.py index db2d6f98a..bf66b977c 100644 --- a/descope/management/project_async.py +++ b/descope/management/project_async.py @@ -154,6 +154,104 @@ async def import_project( ) return + async def delete(self): + """ + Delete the current project. + IMPORTANT: This action is irreversible. Use carefully. + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.post( + MgmtV1.project_delete_path, + body={}, + ) + + async def export_snapshot( + self, + format: Optional[str] = None, + ) -> dict: + """ + Exports a snapshot of all the settings and configurations for a project and returns + the raw JSON files response as a dictionary. + + Args: + format (str): Optional format for the snapshot export. + + Return value (dict): + Return dict containing the exported snapshot data. + + Raise: + AuthException: raised if export operation fails + """ + body = {} + if format: + body["format"] = format + response = await self._http.post( + MgmtV1.project_snapshot_export_path, + body=body, + ) + return response.json() + + async def import_snapshot( + self, + files: dict, + input_secrets: Optional[dict] = None, + excludes: Optional[List[str]] = None, + ): + """ + Imports a snapshot of all settings and configurations into a project, overriding any + current configuration. + + Args: + files (dict): The raw JSON dictionary of files, in the same format as the one + returned by calls to export_snapshot. + input_secrets (dict): Optional secrets that need to be provided for the import. + excludes (List[str]): Optional list of items to exclude from the import. + + Raise: + AuthException: raised if import operation fails + """ + body: dict = {"files": files} + if input_secrets is not None: + body["inputSecrets"] = input_secrets + if excludes is not None: + body["excludes"] = excludes + await self._http.post( + MgmtV1.project_snapshot_import_path, + body=body, + ) + + async def validate_snapshot( + self, + files: dict, + input_secrets: Optional[dict] = None, + ) -> dict: + """ + Validates a snapshot by performing an import dry run and reporting any validation + failures or missing data. This should be called right before import_snapshot to + minimize the risk of the import failing. + + Args: + files (dict): The raw JSON dictionary of files to validate. + input_secrets (dict): Optional secrets to provide for validation. + + Return value (dict): + Return dict containing validation results, including 'ok' boolean and any 'failures' + or 'missingSecrets' if validation fails. + + Raise: + AuthException: raised if validation operation fails + """ + body: dict = {"files": files} + if input_secrets is not None: + body["inputSecrets"] = input_secrets + response = await self._http.post( + MgmtV1.project_snapshot_validate_path, + body=body, + ) + return response.json() + # Function to remove 'tag' field from each project def remove_tag_field(self, projects): return [{k: v for k, v in project.items() if k != "tag"} for project in projects] diff --git a/descope/management/scope_claim_mapping.py b/descope/management/scope_claim_mapping.py new file mode 100644 index 000000000..91bffef88 --- /dev/null +++ b/descope/management/scope_claim_mapping.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import List + +from descope._http_base import HTTPBase +from descope.management.common import MgmtV1 + + +class ScopeClaimMapping(HTTPBase): + def get(self) -> dict: + """ + Get the scope claim mappings for the project. + + Return value (dict): + Return dict in the format {"mappings": [...]} + "mappings" contains a list of scope claim mapping entries. + Each entry has: scope (str), claims (dict), description (str). + + Raise: + AuthException: raised if get operation fails + """ + response = self._http.post( + MgmtV1.scope_claim_mapping_get_path, + body={}, + ) + return response.json() + + def set( + self, + mappings: List[dict], + ): + """ + Set the scope claim mappings for the project. + This will replace all existing mappings. + + Args: + mappings (List[dict]): List of scope claim mapping entries. + Each entry should have: scope (str), claims (dict), description (str). + + Raise: + AuthException: raised if set operation fails + """ + self._http.post( + MgmtV1.scope_claim_mapping_set_path, + body={"mappings": mappings}, + ) + + def delete(self): + """ + Delete all scope claim mappings for the project. + IMPORTANT: This action is irreversible. Use carefully. + + Raise: + AuthException: raised if delete operation fails + """ + self._http.post( + MgmtV1.scope_claim_mapping_delete_path, + body={}, + ) diff --git a/descope/management/scope_claim_mapping_async.py b/descope/management/scope_claim_mapping_async.py new file mode 100644 index 000000000..34b0a4c52 --- /dev/null +++ b/descope/management/scope_claim_mapping_async.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import List + +from descope._http_base import AsyncHTTPBase +from descope.management.common import MgmtV1 + + +class ScopeClaimMappingAsync(AsyncHTTPBase): + """Async counterpart of ScopeClaimMapping — all HTTP calls are coroutines.""" + + async def get(self) -> dict: + """ + Get the scope claim mappings for the project. + + Return value (dict): + Return dict in the format {"mappings": [...]} + "mappings" contains a list of scope claim mapping entries. + Each entry has: scope (str), claims (dict), description (str). + + Raise: + AuthException: raised if get operation fails + """ + response = await self._http.post( + MgmtV1.scope_claim_mapping_get_path, + body={}, + ) + return response.json() + + async def set( + self, + mappings: List[dict], + ): + """ + Set the scope claim mappings for the project. + This will replace all existing mappings. + + Args: + mappings (List[dict]): List of scope claim mapping entries. + Each entry should have: scope (str), claims (dict), description (str). + + Raise: + AuthException: raised if set operation fails + """ + await self._http.post( + MgmtV1.scope_claim_mapping_set_path, + body={"mappings": mappings}, + ) + + async def delete(self): + """ + Delete all scope claim mappings for the project. + IMPORTANT: This action is irreversible. Use carefully. + + Raise: + AuthException: raised if delete operation fails + """ + await self._http.post( + MgmtV1.scope_claim_mapping_delete_path, + body={}, + ) diff --git a/descope/management/sso_application.py b/descope/management/sso_application.py index 5d6f59158..13a54dfd2 100644 --- a/descope/management/sso_application.py +++ b/descope/management/sso_application.py @@ -348,3 +348,184 @@ def load_all( """ response = self._http.get(MgmtV1.sso_application_load_all_path) return response.json() + + def create_wsfed_application( + self, + name: str, + login_page_url: str, + realm: str, + reply_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + reply_allowed_callbacks: Optional[List[str]] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + error_redirect_url: Optional[str] = None, + ) -> dict: + """ + Create a new WS-Federation sso application with the given name. SSO application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The sso application's name. + login_page_url (str): The URL where login page is hosted. + realm (str): WS-Federation realm identifier. + reply_url (str): WS-Federation reply URL. + id (str): Optional sso application ID. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional set the sso application as enabled or disabled. + reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. + + Return value (dict): + Return dict in the format + {"id": } + + Raise: + AuthException: raised if create operation fails + """ + + reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + groups_mapping = [] if groups_mapping is None else groups_mapping + + uri = MgmtV1.sso_application_wsfed_create_path + response = self._http.post( + uri, + body=SSOApplication._compose_create_update_wsfed_body( + name, + login_page_url, + realm, + reply_url, + id, + description, + logo, + enabled, + reply_allowed_callbacks, + attribute_mapping, + groups_mapping, + force_authentication, + logout_redirect_url, + error_redirect_url, + ), + ) + return response.json() + + def update_wsfed_application( + self, + id: str, + name: str, + login_page_url: str, + realm: str, + reply_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + reply_allowed_callbacks: Optional[List[str]] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + error_redirect_url: Optional[str] = None, + ): + """ + Update an existing WS-Federation sso application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing sso application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the sso application to update. + name (str): Updated sso application name + login_page_url (str): The URL where login page is hosted. + realm (str): WS-Federation realm identifier. + reply_url (str): WS-Federation reply URL. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional set the sso application as enabled or disabled. + reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. + + Raise: + AuthException: raised if update operation fails + """ + + reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + groups_mapping = [] if groups_mapping is None else groups_mapping + + uri = MgmtV1.sso_application_wsfed_update_path + self._http.post( + uri, + body=SSOApplication._compose_create_update_wsfed_body( + name, + login_page_url, + realm, + reply_url, + id, + description, + logo, + enabled, + reply_allowed_callbacks, + attribute_mapping, + groups_mapping, + force_authentication, + logout_redirect_url, + error_redirect_url, + ), + ) + + def get_application_secret( + self, + id: str, + ) -> str: + """ + Get the cleartext secret for an SSO application. + + Args: + id (str): The ID of the sso application. + + Return value (str): + Returns the cleartext secret as a string. + + Raise: + AuthException: raised if get operation fails + """ + response = self._http.get( + MgmtV1.sso_application_secret_path, + params={"id": id}, + ) + return response.json().get("cleartext", "") + + def rotate_application_secret( + self, + id: str, + ) -> str: + """ + Rotate the secret for an SSO application. + + Args: + id (str): The ID of the sso application. + + Return value (str): + Returns the new cleartext secret as a string. + + Raise: + AuthException: raised if rotate operation fails + """ + response = self._http.post( + MgmtV1.sso_application_rotate_path, + body={"id": id}, + ) + return response.json().get("cleartext", "") diff --git a/descope/management/sso_application_async.py b/descope/management/sso_application_async.py index 184605a4d..89528dc35 100644 --- a/descope/management/sso_application_async.py +++ b/descope/management/sso_application_async.py @@ -350,3 +350,184 @@ async def load_all( """ response = await self._http.get(MgmtV1.sso_application_load_all_path) return response.json() + + async def create_wsfed_application( + self, + name: str, + login_page_url: str, + realm: str, + reply_url: str, + id: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + reply_allowed_callbacks: Optional[List[str]] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + error_redirect_url: Optional[str] = None, + ) -> dict: + """ + Create a new WS-Federation sso application with the given name. SSO application IDs are provisioned automatically, but can be provided + explicitly if needed. Both the name and ID must be unique per project. + + Args: + name (str): The sso application's name. + login_page_url (str): The URL where login page is hosted. + realm (str): WS-Federation realm identifier. + reply_url (str): WS-Federation reply URL. + id (str): Optional sso application ID. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional set the sso application as enabled or disabled. + reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. + + Return value (dict): + Return dict in the format + {"id": } + + Raise: + AuthException: raised if create operation fails + """ + + reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + groups_mapping = [] if groups_mapping is None else groups_mapping + + uri = MgmtV1.sso_application_wsfed_create_path + response = await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_wsfed_body( + name, + login_page_url, + realm, + reply_url, + id, + description, + logo, + enabled, + reply_allowed_callbacks, + attribute_mapping, + groups_mapping, + force_authentication, + logout_redirect_url, + error_redirect_url, + ), + ) + return response.json() + + async def update_wsfed_application( + self, + id: str, + name: str, + login_page_url: str, + realm: str, + reply_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + enabled: Optional[bool] = True, + reply_allowed_callbacks: Optional[List[str]] = None, + attribute_mapping: Optional[List[SAMLIDPAttributeMappingInfo]] = None, + groups_mapping: Optional[List[SAMLIDPGroupsMappingInfo]] = None, + force_authentication: Optional[bool] = False, + logout_redirect_url: Optional[str] = None, + error_redirect_url: Optional[str] = None, + ): + """ + Update an existing WS-Federation sso application with the given parameters. IMPORTANT: All parameters are used as overrides + to the existing sso application. Empty fields will override populated fields. Use carefully. + + Args: + id (str): The ID of the sso application to update. + name (str): Updated sso application name + login_page_url (str): The URL where login page is hosted. + realm (str): WS-Federation realm identifier. + reply_url (str): WS-Federation reply URL. + description (str): Optional sso application description. + logo (str): Optional sso application logo. + enabled (bool): Optional set the sso application as enabled or disabled. + reply_allowed_callbacks (List[str]): Optional list of allowed callback URLs. + attribute_mapping (List[SAMLIDPAttributeMappingInfo]): Optional list of Descope (IdP) attributes to SP mapping. + groups_mapping (List[SAMLIDPGroupsMappingInfo]): Optional list of Descope (IdP) roles that will be mapped to SP groups. + force_authentication (bool): Optional determine if the IdP should force the user to re-authenticate. + logout_redirect_url (str): Optional Target URL to which the user will be redirected upon logout completion. + error_redirect_url (str): Optional Target URL to which the user will be redirected upon error. + + Raise: + AuthException: raised if update operation fails + """ + + reply_allowed_callbacks = [] if reply_allowed_callbacks is None else reply_allowed_callbacks + attribute_mapping = [] if attribute_mapping is None else attribute_mapping + groups_mapping = [] if groups_mapping is None else groups_mapping + + uri = MgmtV1.sso_application_wsfed_update_path + await self._http.post( + uri, + body=SSOApplicationAsync._compose_create_update_wsfed_body( + name, + login_page_url, + realm, + reply_url, + id, + description, + logo, + enabled, + reply_allowed_callbacks, + attribute_mapping, + groups_mapping, + force_authentication, + logout_redirect_url, + error_redirect_url, + ), + ) + + async def get_application_secret( + self, + id: str, + ) -> str: + """ + Get the cleartext secret for an SSO application. + + Args: + id (str): The ID of the sso application. + + Return value (str): + Returns the cleartext secret as a string. + + Raise: + AuthException: raised if get operation fails + """ + response = await self._http.get( + MgmtV1.sso_application_secret_path, + params={"id": id}, + ) + return response.json().get("cleartext", "") + + async def rotate_application_secret( + self, + id: str, + ) -> str: + """ + Rotate the secret for an SSO application. + + Args: + id (str): The ID of the sso application. + + Return value (str): + Returns the new cleartext secret as a string. + + Raise: + AuthException: raised if rotate operation fails + """ + response = await self._http.post( + MgmtV1.sso_application_rotate_path, + body={"id": id}, + ) + return response.json().get("cleartext", "") diff --git a/descope/management/sso_settings.py b/descope/management/sso_settings.py index 0cd603cbb..755ed7baf 100644 --- a/descope/management/sso_settings.py +++ b/descope/management/sso_settings.py @@ -255,6 +255,94 @@ def recalculate_sso_mappings( body=body, ) + def configure_sso_redirect_url( + self, + tenant_id: str, + saml_redirect_url: Optional[str] = None, + oauth_redirect_url: Optional[str] = None, + sso_id: Optional[str] = None, + ): + """ + Configure SSO redirect URLs for a tenant. + This will override the existing redirect URLs for the tenant and will not affect any other SSO setting. + + Args: + tenant_id (str): The tenant ID to be configured + saml_redirect_url (str): Optional SAML redirect URL + oauth_redirect_url (str): Optional OAuth redirect URL + sso_id (str): Optional SSO identifier for multi-SSO configurations + + Raise: + AuthException: raised if configuration operation fails + """ + body = {"tenantId": tenant_id} + if saml_redirect_url is not None: + body["samlRedirectUrl"] = saml_redirect_url + if oauth_redirect_url is not None: + body["oauthRedirectUrl"] = oauth_redirect_url + if sso_id: + body["ssoId"] = sso_id + + self._http.post( + uri=MgmtV1.sso_redirect_path, + body=body, + ) + + def load_all_settings( + self, + tenant_id: str, + ) -> dict: + """ + Load all SSO settings for the provided tenant_id (for multi-SSO usage). + + Args: + tenant_id (str): The tenant ID of the desired SSO Settings + + Return value (dict): + Containing all loaded SSO settings information. + + Raise: + AuthException: raised if load configuration operation fails + """ + response = self._http.get( + uri=MgmtV1.sso_load_all_settings_path, + params={"tenantId": tenant_id}, + ) + return response.json() + + def new_settings( + self, + tenant_id: str, + display_name: str, + sso_id: Optional[str] = None, + ) -> dict: + """ + Create new SSO settings for a tenant. + + Args: + tenant_id (str): The tenant ID to create settings for + display_name (str): Display name for the SSO settings + sso_id (str): Optional SSO identifier + + Return value (dict): + Containing the created SSO settings information. + + Raise: + AuthException: raised if creation operation fails + """ + body = { + "tenantId": tenant_id, + "displayName": display_name, + } + if sso_id: + body["ssoId"] = sso_id + + response = self._http.post( + uri=MgmtV1.sso_new_settings_path, + body=body, + ) + return response.json() + def delete_settings( self, tenant_id: str, diff --git a/descope/management/sso_settings_async.py b/descope/management/sso_settings_async.py index c61a13853..943841405 100644 --- a/descope/management/sso_settings_async.py +++ b/descope/management/sso_settings_async.py @@ -68,6 +68,94 @@ async def recalculate_sso_mappings( body=body, ) + async def configure_sso_redirect_url( + self, + tenant_id: str, + saml_redirect_url: Optional[str] = None, + oauth_redirect_url: Optional[str] = None, + sso_id: Optional[str] = None, + ): + """ + Configure SSO redirect URLs for a tenant. + This will override the existing redirect URLs for the tenant and will not affect any other SSO setting. + + Args: + tenant_id (str): The tenant ID to be configured + saml_redirect_url (str): Optional SAML redirect URL + oauth_redirect_url (str): Optional OAuth redirect URL + sso_id (str): Optional SSO identifier for multi-SSO configurations + + Raise: + AuthException: raised if configuration operation fails + """ + body = {"tenantId": tenant_id} + if saml_redirect_url is not None: + body["samlRedirectUrl"] = saml_redirect_url + if oauth_redirect_url is not None: + body["oauthRedirectUrl"] = oauth_redirect_url + if sso_id: + body["ssoId"] = sso_id + + await self._http.post( + uri=MgmtV1.sso_redirect_path, + body=body, + ) + + async def load_all_settings( + self, + tenant_id: str, + ) -> dict: + """ + Load all SSO settings for the provided tenant_id (for multi-SSO usage). + + Args: + tenant_id (str): The tenant ID of the desired SSO Settings + + Return value (dict): + Containing all loaded SSO settings information. + + Raise: + AuthException: raised if load configuration operation fails + """ + response = await self._http.get( + uri=MgmtV1.sso_load_all_settings_path, + params={"tenantId": tenant_id}, + ) + return response.json() + + async def new_settings( + self, + tenant_id: str, + display_name: str, + sso_id: Optional[str] = None, + ) -> dict: + """ + Create new SSO settings for a tenant. + + Args: + tenant_id (str): The tenant ID to create settings for + display_name (str): Display name for the SSO settings + sso_id (str): Optional SSO identifier + + Return value (dict): + Containing the created SSO settings information. + + Raise: + AuthException: raised if creation operation fails + """ + body = { + "tenantId": tenant_id, + "displayName": display_name, + } + if sso_id: + body["ssoId"] = sso_id + + response = await self._http.post( + uri=MgmtV1.sso_new_settings_path, + body=body, + ) + return response.json() + async def delete_settings( self, tenant_id: str, diff --git a/descope/management/tenant.py b/descope/management/tenant.py index c0f26b178..8eb405c41 100644 --- a/descope/management/tenant.py +++ b/descope/management/tenant.py @@ -369,3 +369,27 @@ def generate_sso_configuration_link( ) result = response.json() return result.get("adminSSOConfigurationLink", "") + + def revoke_sso_configuration_link( + self, + tenant_id: str, + sso_id: Optional[str] = None, + ): + """ + Revoke a tenant admin self-service link for SSO configuration. + + Args: + tenant_id (str): Tenant ID to revoke the link for. + sso_id (str): Optional SSO identifier for the tenant. + + Raise: + AuthException: raised if revoke operation fails + """ + body: dict[str, Any] = {"tenantId": tenant_id} + if sso_id is not None: + body["ssoId"] = sso_id + + self._http.post( + MgmtV1.tenant_revoke_sso_configuration_link_path, + body=body, + ) diff --git a/descope/management/tenant_async.py b/descope/management/tenant_async.py index 062025d40..282f91cee 100644 --- a/descope/management/tenant_async.py +++ b/descope/management/tenant_async.py @@ -373,3 +373,27 @@ async def generate_sso_configuration_link( ) result = response.json() return result.get("adminSSOConfigurationLink", "") + + async def revoke_sso_configuration_link( + self, + tenant_id: str, + sso_id: Optional[str] = None, + ): + """ + Revoke a tenant admin self-service link for SSO configuration. + + Args: + tenant_id (str): Tenant ID to revoke the link for. + sso_id (str): Optional SSO identifier for the tenant. + + Raise: + AuthException: raised if revoke operation fails + """ + body: dict[str, Any] = {"tenantId": tenant_id} + if sso_id is not None: + body["ssoId"] = sso_id + + await self._http.post( + MgmtV1.tenant_revoke_sso_configuration_link_path, + body=body, + ) diff --git a/descope/management/third_party_application.py b/descope/management/third_party_application.py new file mode 100644 index 000000000..6e17b30da --- /dev/null +++ b/descope/management/third_party_application.py @@ -0,0 +1,358 @@ +from typing import Any, Dict, List, Optional + +from descope._http_base import HTTPBase +from descope.management._third_party_application_base import compose_create_update_body +from descope.management.common import MgmtV1 + + +class ThirdPartyApplication(HTTPBase): + def create( + self, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ) -> dict: + """ + Create a new third party application with the given parameters. + + Args: + name (str): The third party application's name (must be unique per project). + login_page_url (str): The URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + Each scope is a dict with keys: name, description, values. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + Each scope is a dict with keys: name, description, values. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings which used to validate external token. + Dict with key 'issuers' mapping to issuer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens: "projectId", "clientId", or "" (both). + + Return value (dict): + Return dict in the format + {"id": "", "cleartext": ""} + Containing the created third party application ID and secret. + + Raise: + AuthException: raised if creation operation fails + """ + body = compose_create_update_body( + name=name, + login_page_url=login_page_url, + description=description, + logo=logo, + approved_callback_urls=approved_callback_urls, + permissions_scopes=permissions_scopes, + attributes_scopes=attributes_scopes, + jwt_bearer_settings=jwt_bearer_settings, + custom_attributes=custom_attributes, + force_pkce=force_pkce, + default_audience=default_audience, + ) + response = self._http.post(MgmtV1.thirdparty_application_create_path, body=body) + return response.json() + + def update( + self, + id: str, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ): + """ + Update an existing third party application with the given parameters. + IMPORTANT: All parameters are required and will override whatever value is currently + set in the existing third party application. Use carefully. + + Args: + id (str): The ID of the third party application to update. + name (str): Updated third party application name. + login_page_url (str): The URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens. + + Raise: + AuthException: raised if update operation fails + """ + body = compose_create_update_body( + name=name, + login_page_url=login_page_url, + id=id, + description=description, + logo=logo, + approved_callback_urls=approved_callback_urls, + permissions_scopes=permissions_scopes, + attributes_scopes=attributes_scopes, + jwt_bearer_settings=jwt_bearer_settings, + custom_attributes=custom_attributes, + force_pkce=force_pkce, + default_audience=default_audience, + ) + self._http.post(MgmtV1.thirdparty_application_update_path, body=body) + + def patch( + self, + id: str, + name: Optional[str] = None, + login_page_url: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ): + """ + Patch an existing third party application with the given parameters. + Only provided fields will be updated. + + Args: + id (str): The ID of the third party application to patch (required). + name (str): Optional updated third party application name. + login_page_url (str): Optional URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens. + + Raise: + AuthException: raised if patch operation fails + """ + body: Dict[str, Any] = {"id": id} + if name is not None: + body["name"] = name + if login_page_url is not None: + body["loginPageUrl"] = login_page_url + if description is not None: + body["description"] = description + if logo is not None: + body["logo"] = logo + if approved_callback_urls is not None: + body["approvedCallbackUrls"] = approved_callback_urls + if permissions_scopes is not None: + body["permissionsScopes"] = permissions_scopes + if attributes_scopes is not None: + body["attributesScopes"] = attributes_scopes + if jwt_bearer_settings is not None: + body["jwtBearerSettings"] = jwt_bearer_settings + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + if force_pkce is not None: + body["forcePkce"] = force_pkce + if default_audience is not None: + body["defaultAudience"] = default_audience + self._http.post(MgmtV1.thirdparty_application_patch_path, body=body) + + def delete(self, id: str): + """ + Delete an existing third party application. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the third party application to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post(MgmtV1.thirdparty_application_delete_path, body={"id": id}) + + def delete_batch(self, ids: List[str]): + """ + Delete multiple third party applications by id in a single request. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): List of third party application IDs to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post(MgmtV1.thirdparty_application_delete_batch_path, body={"ids": ids}) + + def load(self, id: str) -> dict: + """ + Load third party application by id. + + Args: + id (str): The ID of the third party application to load. + + Return value (dict): + Return dict containing the loaded third party application information with keys: + id, name, description, logo, loginPageUrl, clientId, approvedCallbackUrls, + permissionsScopes, attributesScopes, jwtBearerSettings, customAttributes, + forcePkce, defaultAudience. + + Raise: + AuthException: raised if load operation fails + """ + response = self._http.get(MgmtV1.thirdparty_application_load_path, params={"id": id}) + return response.json() + + def load_all(self) -> dict: + """ + Load all third party applications. + + Return value (dict): + Return dict in the format + {"apps": []} + Containing all third party applications. + + Raise: + AuthException: raised if load operation fails + """ + response = self._http.get(MgmtV1.thirdparty_application_load_all_path) + return response.json() + + def rotate_secret(self, id: str) -> dict: + """ + Rotate the application secret for a third party application by the application id. + + Args: + id (str): The ID of the third party application. + + Return value (dict): + Return dict in the format + {"cleartext": ""} + Containing the new application secret. + + Raise: + AuthException: raised if rotation operation fails + """ + response = self._http.post(MgmtV1.thirdparty_application_rotate_path, body={"id": id}) + return response.json() + + def get_secret(self, id: str) -> dict: + """ + Get the application secret for a third party application by the application id. + + Args: + id (str): The ID of the third party application. + + Return value (dict): + Return dict in the format + {"cleartext": ""} + Containing the application secret. + + Raise: + AuthException: raised if get operation fails + """ + response = self._http.get(MgmtV1.thirdparty_application_secret_path, params={"id": id}) + return response.json() + + def delete_consents( + self, + consent_ids: Optional[List[str]] = None, + app_id: Optional[str] = None, + user_ids: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + ): + """ + Delete consents for third party applications. + + Args: + consent_ids (List[str]): Optional list of consent IDs to delete. + app_id (str): Optional application ID to filter by. + user_ids (List[str]): Optional list of user IDs to filter by. + tenant_id (str): Optional tenant ID to filter by. + + Raise: + AuthException: raised if deletion operation fails + """ + body: Dict[str, Any] = {} + if consent_ids is not None: + body["consentIds"] = consent_ids + if app_id is not None: + body["appId"] = app_id + if user_ids is not None: + body["userIds"] = user_ids + if tenant_id is not None: + body["tenantId"] = tenant_id + self._http.post(MgmtV1.thirdparty_consents_delete_path, body=body) + + def delete_tenant_consents(self, tenant_id: str): + """ + Delete all consents for a specific tenant. + + Args: + tenant_id (str): The tenant ID. + + Raise: + AuthException: raised if deletion operation fails + """ + self._http.post(MgmtV1.thirdparty_consents_delete_tenant_path, body={"tenantId": tenant_id}) + + def search_consents( + self, + app_id: Optional[str] = None, + user_id: Optional[str] = None, + consent_id: Optional[str] = None, + page: Optional[int] = None, + limit: Optional[int] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Search consents for third party applications. + + Args: + app_id (str): Optional application ID to filter by. + user_id (str): Optional user ID to filter by. + consent_id (str): Optional consent ID to filter by. + page (int): Optional page number for pagination. + limit (int): Optional limit for pagination. + tenant_id (str): Optional tenant ID to filter by. + + Return value (dict): + Return dict containing the search results with consent information. + + Raise: + AuthException: raised if search operation fails + """ + body: Dict[str, Any] = {} + if app_id is not None: + body["appId"] = app_id + if user_id is not None: + body["userId"] = user_id + if consent_id is not None: + body["consentId"] = consent_id + if page is not None: + body["page"] = page + if limit is not None: + body["limit"] = limit + if tenant_id is not None: + body["tenantId"] = tenant_id + response = self._http.post(MgmtV1.thirdparty_consents_search_path, body=body) + return response.json() diff --git a/descope/management/third_party_application_async.py b/descope/management/third_party_application_async.py new file mode 100644 index 000000000..03b92b942 --- /dev/null +++ b/descope/management/third_party_application_async.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from descope._http_base import AsyncHTTPBase +from descope.management._third_party_application_base import compose_create_update_body +from descope.management.common import MgmtV1 + + +class ThirdPartyApplicationAsync(AsyncHTTPBase): + async def create( + self, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ) -> dict: + """ + Create a new third party application with the given parameters. + + Args: + name (str): The third party application's name (must be unique per project). + login_page_url (str): The URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + Each scope is a dict with keys: name, description, values. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + Each scope is a dict with keys: name, description, values. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings which used to validate external token. + Dict with key 'issuers' mapping to issuer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens: "projectId", "clientId", or "" (both). + + Return value (dict): + Return dict in the format + {"id": "", "cleartext": ""} + Containing the created third party application ID and secret. + + Raise: + AuthException: raised if creation operation fails + """ + body = compose_create_update_body( + name=name, + login_page_url=login_page_url, + description=description, + logo=logo, + approved_callback_urls=approved_callback_urls, + permissions_scopes=permissions_scopes, + attributes_scopes=attributes_scopes, + jwt_bearer_settings=jwt_bearer_settings, + custom_attributes=custom_attributes, + force_pkce=force_pkce, + default_audience=default_audience, + ) + response = await self._http.post(MgmtV1.thirdparty_application_create_path, body=body) + return response.json() + + async def update( + self, + id: str, + name: str, + login_page_url: str, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ): + """ + Update an existing third party application with the given parameters. + IMPORTANT: All parameters are required and will override whatever value is currently + set in the existing third party application. Use carefully. + + Args: + id (str): The ID of the third party application to update. + name (str): Updated third party application name. + login_page_url (str): The URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens. + + Raise: + AuthException: raised if update operation fails + """ + body = compose_create_update_body( + name=name, + login_page_url=login_page_url, + id=id, + description=description, + logo=logo, + approved_callback_urls=approved_callback_urls, + permissions_scopes=permissions_scopes, + attributes_scopes=attributes_scopes, + jwt_bearer_settings=jwt_bearer_settings, + custom_attributes=custom_attributes, + force_pkce=force_pkce, + default_audience=default_audience, + ) + await self._http.post(MgmtV1.thirdparty_application_update_path, body=body) + + async def patch( + self, + id: str, + name: Optional[str] = None, + login_page_url: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + approved_callback_urls: Optional[List[str]] = None, + permissions_scopes: Optional[List[Dict[str, Any]]] = None, + attributes_scopes: Optional[List[Dict[str, Any]]] = None, + jwt_bearer_settings: Optional[Dict[str, Any]] = None, + custom_attributes: Optional[Dict[str, Any]] = None, + force_pkce: Optional[bool] = None, + default_audience: Optional[str] = None, + ): + """ + Patch an existing third party application with the given parameters. + Only provided fields will be updated. + + Args: + id (str): The ID of the third party application to patch (required). + name (str): Optional updated third party application name. + login_page_url (str): Optional URL where login page is hosted. + description (str): Optional third party application description. + logo (str): Optional third party application logo. + approved_callback_urls (List[str]): Optional list of approved callback URLs. + permissions_scopes (List[Dict[str, Any]]): Optional list of permissions scopes. + attributes_scopes (List[Dict[str, Any]]): Optional list of attributes scopes. + jwt_bearer_settings (Dict[str, Any]): Optional JWT Bearer settings. + custom_attributes (Dict[str, Any]): Optional custom attributes. + force_pkce (bool): Optional flag to require PKCE on the authorization-code flow. + default_audience (str): Optional default aud of issued tokens. + + Raise: + AuthException: raised if patch operation fails + """ + body: Dict[str, Any] = {"id": id} + if name is not None: + body["name"] = name + if login_page_url is not None: + body["loginPageUrl"] = login_page_url + if description is not None: + body["description"] = description + if logo is not None: + body["logo"] = logo + if approved_callback_urls is not None: + body["approvedCallbackUrls"] = approved_callback_urls + if permissions_scopes is not None: + body["permissionsScopes"] = permissions_scopes + if attributes_scopes is not None: + body["attributesScopes"] = attributes_scopes + if jwt_bearer_settings is not None: + body["jwtBearerSettings"] = jwt_bearer_settings + if custom_attributes is not None: + body["customAttributes"] = custom_attributes + if force_pkce is not None: + body["forcePkce"] = force_pkce + if default_audience is not None: + body["defaultAudience"] = default_audience + await self._http.post(MgmtV1.thirdparty_application_patch_path, body=body) + + async def delete(self, id: str): + """ + Delete an existing third party application. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + id (str): The ID of the third party application to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post(MgmtV1.thirdparty_application_delete_path, body={"id": id}) + + async def delete_batch(self, ids: List[str]): + """ + Delete multiple third party applications by id in a single request. + IMPORTANT: This action is irreversible. Use carefully. + + Args: + ids (List[str]): List of third party application IDs to delete. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post(MgmtV1.thirdparty_application_delete_batch_path, body={"ids": ids}) + + async def load(self, id: str) -> dict: + """ + Load third party application by id. + + Args: + id (str): The ID of the third party application to load. + + Return value (dict): + Return dict containing the loaded third party application information with keys: + id, name, description, logo, loginPageUrl, clientId, approvedCallbackUrls, + permissionsScopes, attributesScopes, jwtBearerSettings, customAttributes, + forcePkce, defaultAudience. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.thirdparty_application_load_path, params={"id": id}) + return response.json() + + async def load_all(self) -> dict: + """ + Load all third party applications. + + Return value (dict): + Return dict in the format + {"apps": []} + Containing all third party applications. + + Raise: + AuthException: raised if load operation fails + """ + response = await self._http.get(MgmtV1.thirdparty_application_load_all_path) + return response.json() + + async def rotate_secret(self, id: str) -> dict: + """ + Rotate the application secret for a third party application by the application id. + + Args: + id (str): The ID of the third party application. + + Return value (dict): + Return dict in the format + {"cleartext": ""} + Containing the new application secret. + + Raise: + AuthException: raised if rotation operation fails + """ + response = await self._http.post(MgmtV1.thirdparty_application_rotate_path, body={"id": id}) + return response.json() + + async def get_secret(self, id: str) -> dict: + """ + Get the application secret for a third party application by the application id. + + Args: + id (str): The ID of the third party application. + + Return value (dict): + Return dict in the format + {"cleartext": ""} + Containing the application secret. + + Raise: + AuthException: raised if get operation fails + """ + response = await self._http.get(MgmtV1.thirdparty_application_secret_path, params={"id": id}) + return response.json() + + async def delete_consents( + self, + consent_ids: Optional[List[str]] = None, + app_id: Optional[str] = None, + user_ids: Optional[List[str]] = None, + tenant_id: Optional[str] = None, + ): + """ + Delete consents for third party applications. + + Args: + consent_ids (List[str]): Optional list of consent IDs to delete. + app_id (str): Optional application ID to filter by. + user_ids (List[str]): Optional list of user IDs to filter by. + tenant_id (str): Optional tenant ID to filter by. + + Raise: + AuthException: raised if deletion operation fails + """ + body: Dict[str, Any] = {} + if consent_ids is not None: + body["consentIds"] = consent_ids + if app_id is not None: + body["appId"] = app_id + if user_ids is not None: + body["userIds"] = user_ids + if tenant_id is not None: + body["tenantId"] = tenant_id + await self._http.post(MgmtV1.thirdparty_consents_delete_path, body=body) + + async def delete_tenant_consents(self, tenant_id: str): + """ + Delete all consents for a specific tenant. + + Args: + tenant_id (str): The tenant ID. + + Raise: + AuthException: raised if deletion operation fails + """ + await self._http.post(MgmtV1.thirdparty_consents_delete_tenant_path, body={"tenantId": tenant_id}) + + async def search_consents( + self, + app_id: Optional[str] = None, + user_id: Optional[str] = None, + consent_id: Optional[str] = None, + page: Optional[int] = None, + limit: Optional[int] = None, + tenant_id: Optional[str] = None, + ) -> dict: + """ + Search consents for third party applications. + + Args: + app_id (str): Optional application ID to filter by. + user_id (str): Optional user ID to filter by. + consent_id (str): Optional consent ID to filter by. + page (int): Optional page number for pagination. + limit (int): Optional limit for pagination. + tenant_id (str): Optional tenant ID to filter by. + + Return value (dict): + Return dict containing the search results with consent information. + + Raise: + AuthException: raised if search operation fails + """ + body: Dict[str, Any] = {} + if app_id is not None: + body["appId"] = app_id + if user_id is not None: + body["userId"] = user_id + if consent_id is not None: + body["consentId"] = consent_id + if page is not None: + body["page"] = page + if limit is not None: + body["limit"] = limit + if tenant_id is not None: + body["tenantId"] = tenant_id + response = await self._http.post(MgmtV1.thirdparty_consents_search_path, body=body) + return response.json() diff --git a/descope/management/user.py b/descope/management/user.py index 9ad412265..143b77a71 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -1771,3 +1771,288 @@ def history(self, user_ids: List[str]) -> List[dict]: body=user_ids, ) return response.json() + + def create_custom_attribute( + self, + name: str, + display_name: str, + type: str, + required: Optional[bool] = None, + options: Optional[List[str]] = None, + ) -> dict: + """ + Create a new custom attribute for users. + + Args: + name (str): The name of the custom attribute. + display_name (str): The display name of the custom attribute. + type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). + required (bool): Optional, whether the custom attribute is required. + options (List[str]): Optional, list of options for the custom attribute. + + Return value (dict): + Return dict containing the custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + body: dict = { + "attributes": [ + { + "name": name, + "displayName": display_name, + "type": type, + } + ] + } + if required is not None: + body["attributes"][0]["required"] = required + if options is not None: + body["attributes"][0]["options"] = options + + response = self._http.post( + MgmtV1.user_create_custom_attribute_path, + body=body, + ) + return response.json() + + def delete_custom_attribute( + self, + name: str, + ) -> dict: + """ + Delete a custom attribute for users. + + Args: + name (str): The name of the custom attribute to delete. + + Return value (dict): + Return dict containing the remaining custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.user_delete_custom_attribute_path, + body={"names": [name]}, + ) + return response.json() + + def load_custom_attributes(self) -> dict: + """ + Load all custom attributes for users. + + Return value (dict): + Return dict containing the custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.get( + MgmtV1.user_load_custom_attributes_path, + params={}, + ) + return response.json() + + def delete_batch( + self, + user_ids: List[str], + ): + """ + Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. + + Args: + user_ids (List[str]): List of user IDs to delete. + + Raise: + AuthException: raised if the operation fails + """ + self._http.post( + MgmtV1.user_delete_batch_path, + body={"userIds": user_ids}, + ) + + def import_users( + self, + source: str, + users: Optional[bytes] = None, + hashes: Optional[bytes] = None, + dryrun: bool = False, + ) -> dict: + """ + Import users from an external source. + + Args: + source (str): The source of the users (e.g., "auth0", "firebase"). + users (bytes): Optional, JSON bytes containing the users to import. + hashes (bytes): Optional, JSON bytes containing password hashes. + dryrun (bool): Optional, if True, perform a dry run without actually importing. + + Return value (dict): + Return dict containing the import results. + + Raise: + AuthException: raised if the operation fails + """ + body = { + "source": source, + "dryrun": dryrun, + } + if users is not None: + body["users"] = users + if hashes is not None: + body["hashes"] = hashes + + response = self._http.post( + MgmtV1.user_import_path, + body=body, + ) + return response.json() + + def delete_passkey( + self, + login_id: str, + credential_id: str, + ): + """ + Delete a specific passkey (WebAuthn device) for a user. + + Args: + login_id (str): The login ID of the user. + credential_id (str): The credential ID of the passkey to delete. + + Raise: + AuthException: raised if the operation fails + """ + self._http.post( + MgmtV1.user_delete_passkey_path, + body={"loginId": login_id, "credentialId": credential_id}, + ) + + def list_passkeys( + self, + login_id: str, + ) -> dict: + """ + List all passkeys (WebAuthn devices) registered for a user. + + Args: + login_id (str): The login ID of the user. + + Return value (dict): + Return dict containing the list of passkeys. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.user_list_passkeys_path, + body={"loginId": login_id}, + ) + return response.json() + + def list_trusted_devices( + self, + identifiers: List[str], + ) -> dict: + """ + List all trusted devices for the specified users. + + Args: + identifiers (List[str]): List of login IDs or user IDs. + + Return value (dict): + Return dict containing the list of trusted devices. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.user_list_trusted_devices_path, + body={"identifiers": identifiers}, + ) + return response.json() + + def remove_trusted_device( + self, + identifier: str, + device_ids: List[str], + ): + """ + Remove trusted devices for a user. + + Args: + identifier (str): The login ID or user ID. + device_ids (List[str]): List of device IDs to remove. + + Raise: + AuthException: raised if the operation fails + """ + self._http.post( + MgmtV1.user_remove_trusted_device_path, + body={"identifier": identifier, "deviceIds": device_ids}, + ) + + def update_recovery_email( + self, + login_id: str, + recovery_email: str, + verified: bool = False, + ) -> dict: + """ + Update the recovery email for a user. + + Args: + login_id (str): The login ID of the user. + recovery_email (str): The recovery email address. + verified (bool): Whether the recovery email is verified. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.user_update_recovery_email_path, + body={ + "loginId": login_id, + "recoveryEmail": recovery_email, + "verified": verified, + }, + ) + return response.json() + + def update_recovery_phone( + self, + login_id: str, + recovery_phone: str, + verified: bool = False, + ) -> dict: + """ + Update the recovery phone number for a user. + + Args: + login_id (str): The login ID of the user. + recovery_phone (str): The recovery phone number. + verified (bool): Whether the recovery phone is verified. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = self._http.post( + MgmtV1.user_update_recovery_phone_path, + body={ + "loginId": login_id, + "recoveryPhone": recovery_phone, + "verified": verified, + }, + ) + return response.json() diff --git a/descope/management/user_async.py b/descope/management/user_async.py index 964be3caf..2f840a4fb 100644 --- a/descope/management/user_async.py +++ b/descope/management/user_async.py @@ -1779,3 +1779,288 @@ async def history(self, user_ids: List[str]) -> List[dict]: body=user_ids, ) return response.json() + + async def create_custom_attribute( + self, + name: str, + display_name: str, + type: str, + required: Optional[bool] = None, + options: Optional[List[str]] = None, + ) -> dict: + """ + Create a new custom attribute for users. + + Args: + name (str): The name of the custom attribute. + display_name (str): The display name of the custom attribute. + type (str): The type of the custom attribute (e.g., "string", "number", "boolean"). + required (bool): Optional, whether the custom attribute is required. + options (List[str]): Optional, list of options for the custom attribute. + + Return value (dict): + Return dict containing the custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + body: dict = { + "attributes": [ + { + "name": name, + "displayName": display_name, + "type": type, + } + ] + } + if required is not None: + body["attributes"][0]["required"] = required + if options is not None: + body["attributes"][0]["options"] = options + + response = await self._http.post( + MgmtV1.user_create_custom_attribute_path, + body=body, + ) + return response.json() + + async def delete_custom_attribute( + self, + name: str, + ) -> dict: + """ + Delete a custom attribute for users. + + Args: + name (str): The name of the custom attribute to delete. + + Return value (dict): + Return dict containing the remaining custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_delete_custom_attribute_path, + body={"names": [name]}, + ) + return response.json() + + async def load_custom_attributes(self) -> dict: + """ + Load all custom attributes for users. + + Return value (dict): + Return dict containing the custom attributes list. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.get( + MgmtV1.user_load_custom_attributes_path, + params={}, + ) + return response.json() + + async def delete_batch( + self, + user_ids: List[str], + ): + """ + Delete multiple users by their user IDs. IMPORTANT: This action is irreversible. Use carefully. + + Args: + user_ids (List[str]): List of user IDs to delete. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_delete_batch_path, + body={"userIds": user_ids}, + ) + + async def import_users( + self, + source: str, + users: Optional[bytes] = None, + hashes: Optional[bytes] = None, + dryrun: bool = False, + ) -> dict: + """ + Import users from an external source. + + Args: + source (str): The source of the users (e.g., "auth0", "firebase"). + users (bytes): Optional, JSON bytes containing the users to import. + hashes (bytes): Optional, JSON bytes containing password hashes. + dryrun (bool): Optional, if True, perform a dry run without actually importing. + + Return value (dict): + Return dict containing the import results. + + Raise: + AuthException: raised if the operation fails + """ + body = { + "source": source, + "dryrun": dryrun, + } + if users is not None: + body["users"] = users + if hashes is not None: + body["hashes"] = hashes + + response = await self._http.post( + MgmtV1.user_import_path, + body=body, + ) + return response.json() + + async def delete_passkey( + self, + login_id: str, + credential_id: str, + ): + """ + Delete a specific passkey (WebAuthn device) for a user. + + Args: + login_id (str): The login ID of the user. + credential_id (str): The credential ID of the passkey to delete. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_delete_passkey_path, + body={"loginId": login_id, "credentialId": credential_id}, + ) + + async def list_passkeys( + self, + login_id: str, + ) -> dict: + """ + List all passkeys (WebAuthn devices) registered for a user. + + Args: + login_id (str): The login ID of the user. + + Return value (dict): + Return dict containing the list of passkeys. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_list_passkeys_path, + body={"loginId": login_id}, + ) + return response.json() + + async def list_trusted_devices( + self, + identifiers: List[str], + ) -> dict: + """ + List all trusted devices for the specified users. + + Args: + identifiers (List[str]): List of login IDs or user IDs. + + Return value (dict): + Return dict containing the list of trusted devices. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_list_trusted_devices_path, + body={"identifiers": identifiers}, + ) + return response.json() + + async def remove_trusted_device( + self, + identifier: str, + device_ids: List[str], + ): + """ + Remove trusted devices for a user. + + Args: + identifier (str): The login ID or user ID. + device_ids (List[str]): List of device IDs to remove. + + Raise: + AuthException: raised if the operation fails + """ + await self._http.post( + MgmtV1.user_remove_trusted_device_path, + body={"identifier": identifier, "deviceIds": device_ids}, + ) + + async def update_recovery_email( + self, + login_id: str, + recovery_email: str, + verified: bool = False, + ) -> dict: + """ + Update the recovery email for a user. + + Args: + login_id (str): The login ID of the user. + recovery_email (str): The recovery email address. + verified (bool): Whether the recovery email is verified. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_update_recovery_email_path, + body={ + "loginId": login_id, + "recoveryEmail": recovery_email, + "verified": verified, + }, + ) + return response.json() + + async def update_recovery_phone( + self, + login_id: str, + recovery_phone: str, + verified: bool = False, + ) -> dict: + """ + Update the recovery phone number for a user. + + Args: + login_id (str): The login ID of the user. + recovery_phone (str): The recovery phone number. + verified (bool): Whether the recovery phone is verified. + + Return value (dict): + Return dict in the format + {"user": {}} + Containing the updated user information. + + Raise: + AuthException: raised if the operation fails + """ + response = await self._http.post( + MgmtV1.user_update_recovery_phone_path, + body={ + "loginId": login_id, + "recoveryPhone": recovery_phone, + "verified": verified, + }, + ) + return response.json() diff --git a/descope/mgmt.py b/descope/mgmt.py index ffce2b08d..cab52305f 100644 --- a/descope/mgmt.py +++ b/descope/mgmt.py @@ -4,6 +4,7 @@ from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.http_client import HTTPClient from descope.management.access_key import AccessKey +from descope.management.analytics import Analytics from descope.management.audit import Audit from descope.management.authz import Authz from descope.management.descoper import Descoper @@ -13,19 +14,23 @@ from descope.management.group import Group from descope.management.jwt import JWT from descope.management.license import License +from descope.management.lists import Lists from descope.management.management_key import ManagementKey from descope.management.outbound_application import ( OutboundApplication, OutboundApplicationByToken, ) +from descope.management.password import Password from descope.management.permission import Permission from descope.management.project import Project from descope.management.role import Role +from descope.management.scope_claim_mapping import ScopeClaimMapping from descope.management.sso_application import SSOApplication from descope.management.sso_settings import SSOSettings # Import management modules after adapter to avoid circularities from descope.management.tenant import Tenant +from descope.management.third_party_application import ThirdPartyApplication from descope.management.user import User @@ -40,6 +45,7 @@ def __init__(self, http_client: HTTPClient, auth: Auth, fga_cache_url: Optional[ """ self._http = http_client self._access_key = AccessKey(http_client) + self._analytics = Analytics(http_client) self._audit = Audit(http_client) self._authz = Authz(http_client, fga_cache_url=fga_cache_url) self._descoper = Descoper(http_client) @@ -49,14 +55,18 @@ def __init__(self, http_client: HTTPClient, auth: Auth, fga_cache_url: Optional[ self._group = Group(http_client) self._jwt = JWT(http_client, auth=auth) self._license = License(http_client) + self._lists = Lists(http_client) self._management_key = ManagementKey(http_client) self._outbound_application = OutboundApplication(http_client) self._outbound_application_by_token = OutboundApplicationByToken(http_client) + self._password = Password(http_client) self._permission = Permission(http_client) self._project = Project(http_client) self._role = Role(http_client) + self._scope_claim_mapping = ScopeClaimMapping(http_client) self._sso = SSOSettings(http_client) self._sso_application = SSOApplication(http_client) + self._third_party_application = ThirdPartyApplication(http_client) self._tenant = Tenant(http_client) self._user = User(http_client) @@ -167,3 +177,28 @@ def engine(self): def management_key(self): self._ensure_management_key("management_key") return self._management_key + + @property + def analytics(self): + self._ensure_management_key("analytics") + return self._analytics + + @property + def scope_claim_mapping(self): + self._ensure_management_key("scope_claim_mapping") + return self._scope_claim_mapping + + @property + def list(self): + self._ensure_management_key("list") + return self._lists + + @property + def password(self): + self._ensure_management_key("password") + return self._password + + @property + def third_party_application(self): + self._ensure_management_key("third_party_application") + return self._third_party_application diff --git a/descope/mgmt_async.py b/descope/mgmt_async.py index 61e382e6e..790bb7d3d 100644 --- a/descope/mgmt_async.py +++ b/descope/mgmt_async.py @@ -4,6 +4,7 @@ from descope.exceptions import ERROR_TYPE_INVALID_ARGUMENT, AuthException from descope.http_client_async import HTTPClientAsync from descope.management.access_key_async import AccessKeyAsync +from descope.management.analytics_async import AnalyticsAsync from descope.management.audit_async import AuditAsync from descope.management.authz_async import AuthzAsync from descope.management.descoper_async import DescoperAsync @@ -13,19 +14,25 @@ from descope.management.group_async import GroupAsync from descope.management.jwt_async import JWTAsync from descope.management.license_async import LicenseAsync +from descope.management.lists_async import ListsAsync from descope.management.management_key_async import ManagementKeyAsync from descope.management.outbound_application_async import ( OutboundApplicationAsync, OutboundApplicationByTokenAsync, ) +from descope.management.password_async import PasswordAsync from descope.management.permission_async import PermissionAsync from descope.management.project_async import ProjectAsync from descope.management.role_async import RoleAsync +from descope.management.scope_claim_mapping_async import ScopeClaimMappingAsync from descope.management.sso_application_async import SSOApplicationAsync from descope.management.sso_settings_async import SSOSettingsAsync # Import management modules after adapter to avoid circularities from descope.management.tenant_async import TenantAsync +from descope.management.third_party_application_async import ( + ThirdPartyApplicationAsync, +) from descope.management.user_async import UserAsync @@ -40,6 +47,7 @@ def __init__(self, http_client: HTTPClientAsync, auth: AuthAsync, fga_cache_url: """ self._http = http_client self._access_key = AccessKeyAsync(http_client) + self._analytics = AnalyticsAsync(http_client) self._audit = AuditAsync(http_client) self._authz = AuthzAsync(http_client, fga_cache_url=fga_cache_url) self._descoper = DescoperAsync(http_client) @@ -49,14 +57,18 @@ def __init__(self, http_client: HTTPClientAsync, auth: AuthAsync, fga_cache_url: self._group = GroupAsync(http_client) self._jwt = JWTAsync(http_client, auth=auth) self._license = LicenseAsync(http_client) + self._lists = ListsAsync(http_client) self._management_key = ManagementKeyAsync(http_client) self._outbound_application = OutboundApplicationAsync(http_client) self._outbound_application_by_token = OutboundApplicationByTokenAsync(http_client) + self._password = PasswordAsync(http_client) self._permission = PermissionAsync(http_client) self._project = ProjectAsync(http_client) self._role = RoleAsync(http_client) + self._scope_claim_mapping = ScopeClaimMappingAsync(http_client) self._sso = SSOSettingsAsync(http_client) self._sso_application = SSOApplicationAsync(http_client) + self._third_party_application = ThirdPartyApplicationAsync(http_client) self._tenant = TenantAsync(http_client) self._user = UserAsync(http_client) @@ -167,3 +179,28 @@ def engine(self) -> EngineAsync: def management_key(self) -> ManagementKeyAsync: self._ensure_management_key("management_key") return self._management_key + + @property + def analytics(self) -> AnalyticsAsync: + self._ensure_management_key("analytics") + return self._analytics + + @property + def scope_claim_mapping(self) -> ScopeClaimMappingAsync: + self._ensure_management_key("scope_claim_mapping") + return self._scope_claim_mapping + + @property + def list(self) -> ListsAsync: + self._ensure_management_key("list") + return self._lists + + @property + def password(self) -> PasswordAsync: + self._ensure_management_key("password") + return self._password + + @property + def third_party_application(self) -> ThirdPartyApplicationAsync: + self._ensure_management_key("third_party_application") + return self._third_party_application diff --git a/tests/management/test_access_key.py b/tests/management/test_access_key.py index 26badba0a..65cf02d1c 100644 --- a/tests/management/test_access_key.py +++ b/tests/management/test_access_key.py @@ -251,3 +251,116 @@ async def test_delete(self, client_factory): }, follow_redirects=False, ) + + async def test_rotate(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.rotate("key-id")) + + # Test success flow + with client.mock_mgmt_post(make_response({"key": {"id": "ak1"}, "cleartext": "new-secret"})) as mock_post: + resp = await client.invoke(client.mgmt.access_key.rotate("ak1")) + assert resp["key"]["id"] == "ak1" + assert resp["cleartext"] == "new-secret" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_rotate_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "id": "ak1", + }, + follow_redirects=False, + ) + + async def test_activate_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.activate_batch(["ak1", "ak2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.activate_batch(["ak1", "ak2", "ak3"])) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_activate_batch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "ids": ["ak1", "ak2", "ak3"], + }, + follow_redirects=False, + ) + + async def test_deactivate_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.deactivate_batch(["ak1", "ak2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.deactivate_batch(["ak1", "ak2", "ak3"])) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_deactivate_batch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "ids": ["ak1", "ak2", "ak3"], + }, + follow_redirects=False, + ) + + async def test_delete_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.access_key.delete_batch(["ak1", "ak2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + result = await client.invoke(client.mgmt.access_key.delete_batch(["ak1", "ak2", "ak3"])) + assert result is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.access_key_delete_batch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "ids": ["ak1", "ak2", "ak3"], + }, + follow_redirects=False, + ) diff --git a/tests/management/test_analytics.py b/tests/management/test_analytics.py new file mode 100644 index 000000000..45624bdd9 --- /dev/null +++ b/tests/management/test_analytics.py @@ -0,0 +1,127 @@ +from datetime import datetime + +import pytest + +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + + +class TestAnalytics: + async def test_search(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.analytics.search()) + + # Test success flow + from_ts = datetime(2024, 1, 1, 0, 0, 0) + to_ts = datetime(2024, 12, 31, 23, 59, 59) + json_data = { + "analytics": [ + { + "projectId": "P123", + "action": "login", + "created": "1704067200000", + "device": "Desktop", + "method": "otp", + "geo": "US", + "tenant": "tenant1", + "referrer": "https://example.com", + "cnt": "100", + } + ] + } + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke( + client.mgmt.analytics.search( + actions=["login"], + excluded_actions=["logout"], + from_ts=from_ts, + to_ts=to_ts, + devices=["Desktop"], + methods=["otp"], + geos=["US"], + tenants=["tenant1"], + group_by_action=True, + group_by_device=True, + group_by_method=True, + group_by_geo=True, + group_by_tenant=True, + group_by_referrer=True, + group_by_created="d", + ) + ) + assert resp is not None + assert "analytics" in resp + assert len(resp["analytics"]) == 1 + assert resp["analytics"][0]["action"] == "login" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.analytics_search_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "actions": ["login"], + "excludedActions": ["logout"], + "from": int(from_ts.timestamp() * 1000), + "to": int(to_ts.timestamp() * 1000), + "devices": ["Desktop"], + "methods": ["otp"], + "geos": ["US"], + "tenants": ["tenant1"], + "groupByAction": True, + "groupByDevice": True, + "groupByMethod": True, + "groupByGeo": True, + "groupByTenant": True, + "groupByReferrer": True, + "groupByCreated": "d", + }, + follow_redirects=False, + ) + + async def test_search_minimal(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test success flow with minimal parameters + json_data = {"analytics": []} + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke(client.mgmt.analytics.search()) + assert resp is not None + assert "analytics" in resp + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.analytics_search_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "actions": [], + "excludedActions": [], + "devices": [], + "methods": [], + "geos": [], + "tenants": [], + "groupByAction": False, + "groupByDevice": False, + "groupByMethod": False, + "groupByGeo": False, + "groupByTenant": False, + "groupByReferrer": False, + }, + follow_redirects=False, + ) diff --git a/tests/management/test_audit.py b/tests/management/test_audit.py index 772f076ea..3a90076ad 100644 --- a/tests/management/test_audit.py +++ b/tests/management/test_audit.py @@ -97,3 +97,38 @@ async def test_create_event(self, client_factory): }, follow_redirects=False, ) + + async def test_create_audit_webhook(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed create_audit_webhook + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.audit.create_audit_webhook("webhook-name")) + + # Test success create_audit_webhook + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.audit.create_audit_webhook( + name="webhook-name", + url="https://example.com/webhook", + headers={"Authorization": "Bearer token"}, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.audit_webhook_set_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "name": "webhook-name", + "url": "https://example.com/webhook", + "headers": {"Authorization": "Bearer token"}, + }, + follow_redirects=False, + ) diff --git a/tests/management/test_fga.py b/tests/management/test_fga.py index 53d7659df..4c3e98da7 100644 --- a/tests/management/test_fga.py +++ b/tests/management/test_fga.py @@ -161,6 +161,76 @@ async def test_save_resources_details_error(self, client_factory): with pytest.raises(AuthException): await client.invoke(client.mgmt.fga.save_resources_details(details)) + async def test_load_mappable_schema(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed load_mappable_schema + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.load_mappable_schema("tenant1")) + + # Test success flow + response_body = {"schema": "test"} + with client.mock_mgmt_get(make_response(response_body)) as mock: + result = await client.invoke(client.mgmt.fga.load_mappable_schema("tenant1", resources_limit=10)) + assert result == response_body + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_mappable_schema_path}", + headers=MGMT_HEADERS, + params={"tenantId": "tenant1", "resourcesLimit": "10"}, + follow_redirects=True, + ) + + async def test_load_mappable_resources(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed load_mappable_resources + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.load_mappable_resources("tenant1", [])) + + # Test success flow + response_body = {"mappableResources": [{"id": "r1"}]} + with client.mock_mgmt_post(make_response(response_body)) as mock: + result = await client.invoke( + client.mgmt.fga.load_mappable_resources("tenant1", [{"query": "test"}], resources_limit=5) + ) + assert result == response_body["mappableResources"] + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_mappable_resources_path}", + headers=MGMT_HEADERS, + params=None, + json={"tenantId": "tenant1", "resourcesQueries": [{"query": "test"}], "resourcesLimit": "5"}, + follow_redirects=False, + ) + + async def test_save_schema_dryrun(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed save_schema_dryrun + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.fga.save_schema_dryrun("")) + + # Test success flow + response_body = {"valid": True} + with client.mock_mgmt_post(make_response(response_body)) as mock: + result = await client.invoke(client.mgmt.fga.save_schema_dryrun("model AuthZ 1.0")) + assert result == response_body + assert_http_called( + mock, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.fga_schema_dryrun_path}", + headers=MGMT_HEADERS, + params=None, + json={"dsl": "model AuthZ 1.0"}, + follow_redirects=False, + ) + async def test_fga_cache_url_save_schema(self, client_factory): fga_cache_url = "https://my-fga-cache.example.com" client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key", fga_cache_url=fga_cache_url) diff --git a/tests/management/test_jwt.py b/tests/management/test_jwt.py index 8bcdba95c..e9526c250 100644 --- a/tests/management/test_jwt.py +++ b/tests/management/test_jwt.py @@ -279,6 +279,46 @@ async def test_sign_up_or_in(self, client_factory): params=None, ) + async def test_impersonate_stepup(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response({}, status=500)) as mock: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "imp2", False)) + + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate_stepup("", "imp2", False)) + + with pytest.raises(AuthException): + await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "", False)) + + # Test success flow + with client.mock_mgmt_post(make_response({"jwt": "response"})) as mock: + resp = await client.invoke(client.mgmt.jwt.impersonate_stepup("imp1", "imp2", True)) + assert resp == "response" + expected_uri = f"{DEFAULT_BASE_URL}{MgmtV1.impersonate_stepup_path}" + assert_http_called( + mock, + client.mode, + expected_uri, + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + json={ + "loginId": "imp2", + "impersonatorId": "imp1", + "validateConsent": True, + "customClaims": None, + "selectedTenant": None, + "refreshDuration": None, + }, + follow_redirects=False, + params=None, + ) + async def test_anonymous(self, client_factory): client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") diff --git a/tests/management/test_lists.py b/tests/management/test_lists.py new file mode 100644 index 000000000..1026a713f --- /dev/null +++ b/tests/management/test_lists.py @@ -0,0 +1,318 @@ +import pytest + +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + +MGMT_HEADERS = { + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, +} + +LIST_RESPONSE = { + "list": { + "id": "list1", + "name": "my-list", + "description": "Test list", + "type": "texts", + "data": ["item1", "item2"], + "createdTime": 1719571200, + } +} + + +class TestLists: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.create("my-list", "texts", "Test list", ["item1"])) + + # Test success flow + with client.mock_mgmt_post(make_response(LIST_RESPONSE)) as mock_post: + resp = await client.invoke(client.mgmt.list.create("my-list", "texts", "Test list", ["item1"])) + assert resp["list"]["id"] == "list1" + assert resp["list"]["name"] == "my-list" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_path}", + headers=MGMT_HEADERS, + params=None, + json={ + "name": "my-list", + "type": "texts", + "description": "Test list", + "data": ["item1"], + }, + follow_redirects=False, + ) + + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.update("list1", "renamed", "ips", None, ["1.2.3.4"])) + + with client.mock_mgmt_post(make_response({"list": {"id": "list1", "name": "renamed"}})) as mock_post: + resp = await client.invoke(client.mgmt.list.update("list1", "renamed", "ips", None, ["1.2.3.4"])) + assert resp["list"]["name"] == "renamed" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_update_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "name": "renamed", "type": "ips", "data": ["1.2.3.4"]}, + follow_redirects=False, + ) + + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.delete("list1")) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.delete("list1")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_delete_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1"}, + follow_redirects=False, + ) + + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.load("list1")) + + with client.mock_mgmt_get(make_response({"list": {"id": "list1", "name": "my-list"}})) as mock_get: + resp = await client.invoke(client.mgmt.list.load("list1")) + assert resp["list"]["id"] == "list1" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_path}", + headers=MGMT_HEADERS, + params={"id": "list1"}, + follow_redirects=True, + ) + + async def test_load_by_name(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.load_by_name("my-list")) + + with client.mock_mgmt_get(make_response({"list": {"id": "list1", "name": "my-list"}})) as mock_get: + resp = await client.invoke(client.mgmt.list.load_by_name("my-list")) + assert resp["list"]["name"] == "my-list" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_name_path}", + headers=MGMT_HEADERS, + params={"name": "my-list"}, + follow_redirects=True, + ) + + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.load_all()) + + with client.mock_mgmt_get(make_response({"lists": [{"id": "list1"}, {"id": "list2"}]})) as mock_get: + resp = await client.invoke(client.mgmt.list.load_all()) + assert len(resp["lists"]) == 2 + assert resp["lists"][0]["id"] == "list1" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_all_path}", + headers=MGMT_HEADERS, + params=None, + follow_redirects=True, + ) + + async def test_import_lists(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + lists_to_import = [ + {"id": "list1", "name": "List 1", "type": "texts", "data": ["a", "b"]}, + {"id": "list2", "name": "List 2", "type": "ips", "data": ["1.2.3.4"]}, + ] + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.import_lists(lists_to_import)) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.import_lists(lists_to_import)) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_import_path}", + headers=MGMT_HEADERS, + params=None, + json={"lists": lists_to_import}, + follow_redirects=False, + ) + + async def test_add_ips(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.add_ips("list1", ["1.2.3.4", "5.6.7.8"])) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.add_ips("list1", ["1.2.3.4", "5.6.7.8"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_add_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "ips": ["1.2.3.4", "5.6.7.8"]}, + follow_redirects=False, + ) + + async def test_remove_ips(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.remove_ips("list1", ["1.2.3.4"])) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.remove_ips("list1", ["1.2.3.4"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_remove_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "ips": ["1.2.3.4"]}, + follow_redirects=False, + ) + + async def test_check_ip(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) + + with client.mock_mgmt_post(make_response({"exists": True})) as mock_post: + result = await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) + assert result is True + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_ip_check_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "ip": "1.2.3.4"}, + follow_redirects=False, + ) + + with client.mock_mgmt_post(make_response({"exists": False})) as mock_post: + result = await client.invoke(client.mgmt.list.check_ip("list1", "1.2.3.4")) + assert result is False + + async def test_add_texts(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.add_texts("list1", ["text1", "text2"])) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.add_texts("list1", ["text1", "text2"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_text_add_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "texts": ["text1", "text2"]}, + follow_redirects=False, + ) + + async def test_remove_texts(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.remove_texts("list1", ["text1"])) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.remove_texts("list1", ["text1"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_text_remove_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "texts": ["text1"]}, + follow_redirects=False, + ) + + async def test_check_text(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.check_text("list1", "text1")) + + with client.mock_mgmt_post(make_response({"exists": True})) as mock_post: + result = await client.invoke(client.mgmt.list.check_text("list1", "text1")) + assert result is True + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_text_check_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1", "text": "text1"}, + follow_redirects=False, + ) + + with client.mock_mgmt_post(make_response({"exists": False})) as mock_post: + result = await client.invoke(client.mgmt.list.check_text("list1", "text1")) + assert result is False + + async def test_clear(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.list.clear("list1")) + + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.list.clear("list1")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.list_clear_path}", + headers=MGMT_HEADERS, + params=None, + json={"id": "list1"}, + follow_redirects=False, + ) diff --git a/tests/management/test_password.py b/tests/management/test_password.py new file mode 100644 index 000000000..d22eecf29 --- /dev/null +++ b/tests/management/test_password.py @@ -0,0 +1,91 @@ +import pytest + +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + + +@pytest.mark.asyncio +class TestPassword: + async def test_get_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed get_settings + with client.mock_mgmt_get(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.password.get_settings()) + + # Test success get_settings + settings_resp = { + "enabled": True, + "minLength": 8, + "lowercase": True, + "uppercase": True, + "number": True, + "nonAlphanumeric": False, + "expiration": False, + "expirationWeeks": 0, + "reuse": False, + "reuseAmount": 0, + "lock": True, + "lockAttempts": 5, + } + with client.mock_mgmt_get(make_response(settings_resp)) as mock_get: + resp = await client.invoke(client.mgmt.password.get_settings("tenant-id")) + assert resp == settings_resp + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.password_settings_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={"tenantId": "tenant-id"}, + follow_redirects=True, + ) + + async def test_configure_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed configure_settings + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.password.configure_settings("", {})) + + # Test success configure_settings + settings = { + "enabled": True, + "minLength": 10, + "lowercase": True, + "uppercase": True, + "number": True, + "nonAlphanumeric": True, + "expiration": True, + "expirationWeeks": 12, + "reuse": True, + "reuseAmount": 3, + "lock": True, + "lockAttempts": 5, + } + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.password.configure_settings("tenant-id", settings)) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.password_settings_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "tenantId": "tenant-id", + **settings, + }, + follow_redirects=False, + ) diff --git a/tests/management/test_project.py b/tests/management/test_project.py index 3446d0a7a..d75dd336f 100644 --- a/tests/management/test_project.py +++ b/tests/management/test_project.py @@ -198,3 +198,118 @@ async def test_import_project(self, client_factory): }, follow_redirects=False, ) + + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.delete()) + + # Test success flow + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.project.delete()) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_delete_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={}, + follow_redirects=False, + ) + + async def test_export_snapshot(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.export_snapshot()) + + # Test success flow + json_data = {"files": {"flow.json": "{}"}, "format": "v1"} + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke(client.mgmt.project.export_snapshot("v1")) + assert resp is not None + assert "files" in resp + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_export_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"format": "v1"}, + follow_redirects=False, + ) + + async def test_import_snapshot(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.import_snapshot({"flow.json": "{}"})) + + # Test success flow + files = {"flow.json": "{}"} + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke(client.mgmt.project.import_snapshot(files, {"secret": "value"}, ["lists"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_import_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "files": {"flow.json": "{}"}, + "inputSecrets": {"secret": "value"}, + "excludes": ["lists"], + }, + follow_redirects=False, + ) + + async def test_validate_snapshot(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.project.validate_snapshot({"flow.json": "{}"})) + + # Test success flow + files = {"flow.json": "{}"} + json_data = {"ok": True, "failures": [], "missingSecrets": []} + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke(client.mgmt.project.validate_snapshot(files, {"secret": "value"})) + assert resp is not None + assert resp["ok"] is True + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.project_snapshot_validate_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "files": {"flow.json": "{}"}, + "inputSecrets": {"secret": "value"}, + }, + follow_redirects=False, + ) diff --git a/tests/management/test_scope_claim_mapping.py b/tests/management/test_scope_claim_mapping.py new file mode 100644 index 000000000..2670b98d0 --- /dev/null +++ b/tests/management/test_scope_claim_mapping.py @@ -0,0 +1,112 @@ +import pytest + +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + + +class TestScopeClaimMapping: + async def test_get(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.scope_claim_mapping.get()) + + # Test success flow + json_data = { + "mappings": [ + { + "scope": "openid", + "claims": {"sub": "userId", "email": "email"}, + "description": "OpenID scope mapping", + } + ] + } + with client.mock_mgmt_post(make_response(json_data)) as mock_post: + resp = await client.invoke(client.mgmt.scope_claim_mapping.get()) + assert resp is not None + assert "mappings" in resp + assert len(resp["mappings"]) == 1 + assert resp["mappings"][0]["scope"] == "openid" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_get_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={}, + follow_redirects=False, + ) + + async def test_set(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.scope_claim_mapping.set([])) + + # Test success flow + mappings = [ + { + "scope": "profile", + "claims": {"name": "name", "picture": "picture"}, + "description": "Profile scope mapping", + } + ] + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.scope_claim_mapping.set(mappings)) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_set_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "mappings": [ + { + "scope": "profile", + "claims": {"name": "name", "picture": "picture"}, + "description": "Profile scope mapping", + } + ] + }, + follow_redirects=False, + ) + + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)): + with pytest.raises(AuthException): + await client.invoke(client.mgmt.scope_claim_mapping.delete()) + + # Test success flow + with client.mock_mgmt_post(make_response()) as mock_post: + assert await client.invoke(client.mgmt.scope_claim_mapping.delete()) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.scope_claim_mapping_delete_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={}, + follow_redirects=False, + ) diff --git a/tests/management/test_sso_application.py b/tests/management/test_sso_application.py index 364f393ca..e3de5bbac 100644 --- a/tests/management/test_sso_application.py +++ b/tests/management/test_sso_application.py @@ -529,3 +529,167 @@ async def test_load_all(self, client_factory): params=None, follow_redirects=True, ) + + async def test_create_wsfed_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.create_wsfed_application( + "valid-name", + "http://dummy.com", + "urn:realm", + "http://reply.com", + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response({"id": "app1"})) as mock_post: + resp = await client.invoke( + client.mgmt.sso_application.create_wsfed_application( + name="name", + login_page_url="http://dummy.com", + realm="urn:realm", + reply_url="http://reply.com", + force_authentication=True, + ) + ) + assert resp["id"] == "app1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_wsfed_create_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "name": "name", + "loginPageUrl": "http://dummy.com", + "realm": "urn:realm", + "replyUrl": "http://reply.com", + "enabled": True, + "id": None, + "description": None, + "logo": None, + "replyAllowedCallbacks": [], + "attributeMapping": [], + "groupsMapping": [], + "forceAuthentication": True, + "logoutRedirectUrl": None, + "errorRedirectUrl": None, + }, + follow_redirects=False, + ) + + async def test_update_wsfed_application(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso_application.update_wsfed_application( + "app1", + "valid-name", + "http://dummy.com", + "urn:realm", + "http://reply.com", + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.sso_application.update_wsfed_application( + id="app1", + name="name", + login_page_url="http://dummy.com", + realm="urn:realm", + reply_url="http://reply.com", + force_authentication=True, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_wsfed_update_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "name": "name", + "loginPageUrl": "http://dummy.com", + "realm": "urn:realm", + "replyUrl": "http://reply.com", + "enabled": True, + "id": "app1", + "description": None, + "logo": None, + "replyAllowedCallbacks": [], + "attributeMapping": [], + "groupsMapping": [], + "forceAuthentication": True, + "logoutRedirectUrl": None, + "errorRedirectUrl": None, + }, + follow_redirects=False, + ) + + async def test_get_application_secret(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=400)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso_application.get_application_secret("app1")) + + # Test success flow + with client.mock_mgmt_get(make_response({"cleartext": "secret123"})) as mock_get: + secret = await client.invoke(client.mgmt.sso_application.get_application_secret("app1")) + assert secret == "secret123" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_secret_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={"id": "app1"}, + follow_redirects=True, + ) + + async def test_rotate_application_secret(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=400)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso_application.rotate_application_secret("app1")) + + # Test success flow + with client.mock_mgmt_post(make_response({"cleartext": "newsecret456"})) as mock_post: + secret = await client.invoke(client.mgmt.sso_application.rotate_application_secret("app1")) + assert secret == "newsecret456" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_application_rotate_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"id": "app1"}, + follow_redirects=False, + ) diff --git a/tests/management/test_sso_settings.py b/tests/management/test_sso_settings.py index 3cb288bdc..feb20de25 100644 --- a/tests/management/test_sso_settings.py +++ b/tests/management/test_sso_settings.py @@ -999,3 +999,104 @@ async def test_recalculate_sso_mappings(self, client_factory): }, follow_redirects=False, ) + + async def test_configure_sso_redirect_url(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.sso.configure_sso_redirect_url( + "tenant-id", saml_redirect_url="https://example.com/saml" + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.sso.configure_sso_redirect_url( + "tenant-id", + saml_redirect_url="https://example.com/saml", + oauth_redirect_url="https://example.com/oauth", + sso_id="sso123", + ) + ) + + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_redirect_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "tenantId": "tenant-id", + "samlRedirectUrl": "https://example.com/saml", + "oauthRedirectUrl": "https://example.com/oauth", + "ssoId": "sso123", + }, + follow_redirects=False, + ) + + async def test_load_all_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.load_all_settings("tenant-id")) + + # Test success flow + resp_data = json.loads("""[{"tenant": {"id": "T2AAAA"}, "saml": {}}]""") + with client.mock_mgmt_get(make_response(resp_data)) as mock_get: + resp = await client.invoke(client.mgmt.sso.load_all_settings("T2AAAA")) + assert isinstance(resp, list) + + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_load_all_settings_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={"tenantId": "T2AAAA"}, + follow_redirects=True, + ) + + async def test_new_settings(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.sso.new_settings("tenant-id", "My SSO")) + + # Test success flow + resp_data = json.loads("""{"tenant": {"id": "T2AAAA"}, "saml": {}}""") + with client.mock_mgmt_post(make_response(resp_data)) as mock_post: + resp = await client.invoke(client.mgmt.sso.new_settings("T2AAAA", "My SSO", sso_id="sso123")) + assert resp.get("tenant", {}).get("id") == "T2AAAA" + + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.sso_new_settings_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "tenantId": "T2AAAA", + "displayName": "My SSO", + "ssoId": "sso123", + }, + follow_redirects=False, + ) diff --git a/tests/management/test_tenant.py b/tests/management/test_tenant.py index 7f682e955..c5ae862fa 100644 --- a/tests/management/test_tenant.py +++ b/tests/management/test_tenant.py @@ -556,3 +556,29 @@ async def test_generate_sso_configuration_link_with_actor(self, client_factory): json={"tenantId": "t1", "actorId": "admin-actor-1"}, follow_redirects=False, ) + + async def test_revoke_sso_configuration_link(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flow + with client.mock_mgmt_post(make_response(status=500)) as _: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.tenant.revoke_sso_configuration_link(tenant_id="t1")) + + # Test success flow + with client.mock_mgmt_post(make_response()) as mock_post: + await client.invoke( + client.mgmt.tenant.revoke_sso_configuration_link( + tenant_id="t1", + sso_id="sso123", + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.tenant_revoke_sso_configuration_link_path}", + headers=MGMT_HEADERS, + params=None, + json={"tenantId": "t1", "ssoId": "sso123"}, + follow_redirects=False, + ) diff --git a/tests/management/test_third_party_application.py b/tests/management/test_third_party_application.py new file mode 100644 index 000000000..cbd8dd838 --- /dev/null +++ b/tests/management/test_third_party_application.py @@ -0,0 +1,421 @@ +import pytest + +from descope import AuthException +from descope.management.common import MgmtV1 +from tests.common import DEFAULT_BASE_URL, default_headers +from tests.conftest import PROJECT_ID, assert_http_called, make_response +from tests.testutils import PUBLIC_KEY_DICT + + +class TestThirdPartyApplication: + async def test_create(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.third_party_application.create( + "valid-name", + "http://dummy.com", + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response({"id": "app1", "cleartext": "secret123"})) as mock_post: + resp = await client.invoke( + client.mgmt.third_party_application.create( + name="test-app", + login_page_url="http://dummy.com", + description="Test application", + approved_callback_urls=["http://callback.com"], + force_pkce=True, + ) + ) + assert resp["id"] == "app1" + assert resp["cleartext"] == "secret123" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_create_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "name": "test-app", + "loginPageUrl": "http://dummy.com", + "description": "Test application", + "approvedCallbackUrls": ["http://callback.com"], + "forcePkce": True, + }, + follow_redirects=False, + ) + + async def test_update(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.third_party_application.update( + "app1", + "valid-name", + "http://dummy.com", + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.third_party_application.update( + id="app1", + name="updated-app", + login_page_url="http://updated.com", + description="Updated application", + logo="http://logo.png", + default_audience="projectId", + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_update_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "id": "app1", + "name": "updated-app", + "loginPageUrl": "http://updated.com", + "description": "Updated application", + "logo": "http://logo.png", + "defaultAudience": "projectId", + }, + follow_redirects=False, + ) + + async def test_patch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.third_party_application.patch( + "app1", + name="new-name", + ) + ) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.third_party_application.patch( + id="app1", + name="patched-app", + force_pkce=False, + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_patch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "id": "app1", + "name": "patched-app", + "forcePkce": False, + }, + follow_redirects=False, + ) + + async def test_delete(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.delete("app1")) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.third_party_application.delete("app1")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_delete_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"id": "app1"}, + follow_redirects=False, + ) + + async def test_delete_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.delete_batch(["app1", "app2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.third_party_application.delete_batch(["app1", "app2"])) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_delete_batch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"ids": ["app1", "app2"]}, + follow_redirects=False, + ) + + async def test_load(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.load("app1")) + + # Test success flow + with client.mock_mgmt_get( + make_response( + { + "id": "app1", + "name": "test-app", + "loginPageUrl": "http://dummy.com", + "clientId": "client123", + } + ) + ) as mock_get: + resp = await client.invoke(client.mgmt.third_party_application.load("app1")) + assert resp["id"] == "app1" + assert resp["name"] == "test-app" + assert resp["clientId"] == "client123" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_load_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={"id": "app1"}, + follow_redirects=True, + ) + + async def test_load_all(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.load_all()) + + # Test success flow + with client.mock_mgmt_get( + make_response( + { + "apps": [ + {"id": "app1", "name": "test-app1"}, + {"id": "app2", "name": "test-app2"}, + ] + } + ) + ) as mock_get: + resp = await client.invoke(client.mgmt.third_party_application.load_all()) + assert len(resp["apps"]) == 2 + assert resp["apps"][0]["id"] == "app1" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_load_all_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + follow_redirects=True, + ) + + async def test_rotate_secret(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.rotate_secret("app1")) + + # Test success flow + with client.mock_mgmt_post(make_response({"cleartext": "new-secret123"})) as mock_post: + resp = await client.invoke(client.mgmt.third_party_application.rotate_secret("app1")) + assert resp["cleartext"] == "new-secret123" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_rotate_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"id": "app1"}, + follow_redirects=False, + ) + + async def test_get_secret(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.get_secret("app1")) + + # Test success flow + with client.mock_mgmt_get(make_response({"cleartext": "secret123"})) as mock_get: + resp = await client.invoke(client.mgmt.third_party_application.get_secret("app1")) + assert resp["cleartext"] == "secret123" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_application_secret_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={"id": "app1"}, + follow_redirects=True, + ) + + async def test_delete_consents(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke( + client.mgmt.third_party_application.delete_consents(app_id="app1", user_ids=["user1"]) + ) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke( + client.mgmt.third_party_application.delete_consents( + consent_ids=["c1", "c2"], + app_id="app1", + user_ids=["user1"], + tenant_id="tenant1", + ) + ) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_delete_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "consentIds": ["c1", "c2"], + "appId": "app1", + "userIds": ["user1"], + "tenantId": "tenant1", + }, + follow_redirects=False, + ) + + async def test_delete_tenant_consents(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.delete_tenant_consents("tenant1")) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + await client.invoke(client.mgmt.third_party_application.delete_tenant_consents("tenant1")) + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_delete_tenant_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"tenantId": "tenant1"}, + follow_redirects=False, + ) + + async def test_search_consents(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.third_party_application.search_consents(app_id="app1")) + + # Test success flow + with client.mock_mgmt_post( + make_response({"consents": [{"consentId": "c1", "appId": "app1", "userId": "user1"}]}) + ) as mock_post: + resp = await client.invoke( + client.mgmt.third_party_application.search_consents( + app_id="app1", + user_id="user1", + page=1, + limit=10, + tenant_id="tenant1", + ) + ) + assert len(resp["consents"]) == 1 + assert resp["consents"][0]["consentId"] == "c1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.thirdparty_consents_search_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "appId": "app1", + "userId": "user1", + "page": 1, + "limit": 10, + "tenantId": "tenant1", + }, + follow_redirects=False, + ) diff --git a/tests/management/test_user.py b/tests/management/test_user.py index b37fa64bb..e30dba9ed 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -2509,3 +2509,315 @@ async def test_patch_test_user(self, client_factory): }, follow_redirects=False, ) + + async def test_create_custom_attribute(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.create_custom_attribute("attr1", "Attribute 1", "string")) + + # Test success flow + with client.mock_mgmt_post(make_response({"attributes": [{"name": "attr1"}]})) as mock_post: + resp = await client.invoke( + client.mgmt.user.create_custom_attribute( + "attr1", "Attribute 1", "string", required=True, options=["a", "b"] + ) + ) + assert resp["attributes"][0]["name"] == "attr1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_create_custom_attribute_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "attributes": [ + { + "name": "attr1", + "displayName": "Attribute 1", + "type": "string", + "required": True, + "options": ["a", "b"], + } + ] + }, + follow_redirects=False, + ) + + async def test_delete_custom_attribute(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete_custom_attribute("attr1")) + + # Test success flow + with client.mock_mgmt_post(make_response({"attributes": []})) as mock_post: + resp = await client.invoke(client.mgmt.user.delete_custom_attribute("attr1")) + assert resp["attributes"] == [] + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_custom_attribute_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"names": ["attr1"]}, + follow_redirects=False, + ) + + async def test_load_custom_attributes(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_get(make_response(status=500)) as mock_get: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.load_custom_attributes()) + + # Test success flow + with client.mock_mgmt_get(make_response({"attributes": [{"name": "attr1"}]})) as mock_get: + resp = await client.invoke(client.mgmt.user.load_custom_attributes()) + assert resp["attributes"][0]["name"] == "attr1" + assert_http_called( + mock_get, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_load_custom_attributes_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params={}, + follow_redirects=True, + ) + + async def test_delete_batch(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete_batch(["u1", "u2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.delete_batch(["u1", "u2"])) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_batch_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"userIds": ["u1", "u2"]}, + follow_redirects=False, + ) + + async def test_import_users(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.import_users("auth0", b'{"users":[]}')) + + # Test success flow + with client.mock_mgmt_post(make_response({"imported": 2, "failed": 0})) as mock_post: + resp = await client.invoke( + client.mgmt.user.import_users("auth0", b'{"users":[]}', b'{"hashes":[]}', dryrun=True) + ) + assert resp["imported"] == 2 + assert resp["failed"] == 0 + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_import_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "source": "auth0", + "users": b'{"users":[]}', + "hashes": b'{"hashes":[]}', + "dryrun": True, + }, + follow_redirects=False, + ) + + async def test_delete_passkey(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.delete_passkey("user1", "cred123")) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.delete_passkey("user1", "cred123")) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_delete_passkey_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "user1", "credentialId": "cred123"}, + follow_redirects=False, + ) + + async def test_list_passkeys(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.list_passkeys("user1")) + + # Test success flow + with client.mock_mgmt_post(make_response({"passkeys": [{"id": "pk1"}]})) as mock_post: + resp = await client.invoke(client.mgmt.user.list_passkeys("user1")) + assert resp["passkeys"][0]["id"] == "pk1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_list_passkeys_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"loginId": "user1"}, + follow_redirects=False, + ) + + async def test_list_trusted_devices(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.list_trusted_devices(["user1", "user2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({"devices": [{"id": "dev1"}]})) as mock_post: + resp = await client.invoke(client.mgmt.user.list_trusted_devices(["user1", "user2"])) + assert resp["devices"][0]["id"] == "dev1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_list_trusted_devices_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"identifiers": ["user1", "user2"]}, + follow_redirects=False, + ) + + async def test_remove_trusted_device(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.remove_trusted_device("user1", ["dev1", "dev2"])) + + # Test success flow + with client.mock_mgmt_post(make_response({})) as mock_post: + assert await client.invoke(client.mgmt.user.remove_trusted_device("user1", ["dev1", "dev2"])) is None + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_remove_trusted_device_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={"identifier": "user1", "deviceIds": ["dev1", "dev2"]}, + follow_redirects=False, + ) + + async def test_update_recovery_email(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_recovery_email("user1", "recovery@example.com", True)) + + # Test success flow + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_recovery_email("user1", "recovery@example.com", True)) + assert resp["user"]["id"] == "u1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_recovery_email_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "user1", + "recoveryEmail": "recovery@example.com", + "verified": True, + }, + follow_redirects=False, + ) + + async def test_update_recovery_phone(self, client_factory): + client = client_factory.make(PROJECT_ID, PUBLIC_KEY_DICT, False, "key") + + # Test failed flows + with client.mock_mgmt_post(make_response(status=500)) as mock_post: + with pytest.raises(AuthException): + await client.invoke(client.mgmt.user.update_recovery_phone("user1", "+1234567890", True)) + + # Test success flow + with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: + resp = await client.invoke(client.mgmt.user.update_recovery_phone("user1", "+1234567890", True)) + assert resp["user"]["id"] == "u1" + assert_http_called( + mock_post, + client.mode, + f"{DEFAULT_BASE_URL}{MgmtV1.user_update_recovery_phone_path}", + headers={ + **default_headers, + "Authorization": f"Bearer {PROJECT_ID}:key", + "x-descope-project-id": PROJECT_ID, + }, + params=None, + json={ + "loginId": "user1", + "recoveryPhone": "+1234567890", + "verified": True, + }, + follow_redirects=False, + ) From b8480fb3fdd84ffa81cdb89ec2931cea4aa67c57 Mon Sep 17 00:00:00 2001 From: Doron Sharon Date: Mon, 29 Jun 2026 15:46:41 +0000 Subject: [PATCH 2/3] fix(management): base64-encode user import payloads; document new mgmt modules import_users put raw bytes into the JSON body, which crashed at runtime (bytes are not JSON-serializable). Encode users/hashes as base64 to match the go-sdk wire format (Go marshals []byte as base64). Update the test to assert the encoded body instead of raw bytes. Add README sections for the new management modules: third party applications, lists, password settings, analytics, and scope claim mapping. Co-Authored-By: Claude Opus 4.8 --- README.md | 133 +++++++++++++++++++++++++++++++ descope/management/user.py | 5 +- descope/management/user_async.py | 5 +- tests/management/test_user.py | 4 +- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bcc0ae897..e01e34fd0 100644 --- a/README.md +++ b/README.md @@ -1729,6 +1729,139 @@ latest_tenant_token = descope_client.mgmt.outbound_application_by_token.fetch_te ) ``` +### Manage Third Party Applications + +Third party applications let external apps use Descope as an identity provider via OAuth/OIDC. + +```python +# Create a third party application (returns the generated id and secret) +resp = descope_client.mgmt.third_party_application.create( + name="my-app", + login_page_url="https://my-app.com/login", + description="My third party app", # Optional + approved_callback_urls=["https://my-app.com/callback"], # Optional + permissions_scopes=[{"name": "read", "description": "Read access", "values": ["roleA"]}], # Optional + force_pkce=True, # Optional +) +app_id = resp["id"] + +# Update will override all fields as is. Use carefully. +descope_client.mgmt.third_party_application.update( + id=app_id, + name="my-app", + login_page_url="https://my-app.com/login", +) + +# Patch only the provided fields +descope_client.mgmt.third_party_application.patch(id=app_id, description="updated") + +# Load / load all +app = descope_client.mgmt.third_party_application.load(app_id) +all_apps = descope_client.mgmt.third_party_application.load_all() + +# Rotate or fetch the application secret +new_secret = descope_client.mgmt.third_party_application.rotate_secret(app_id) +secret = descope_client.mgmt.third_party_application.get_secret(app_id) + +# Search and delete user consents +consents = descope_client.mgmt.third_party_application.search_consents(app_id=app_id, limit=10) +descope_client.mgmt.third_party_application.delete_consents(app_id=app_id, user_ids=["u1"]) +descope_client.mgmt.third_party_application.delete_tenant_consents("tenant-id") + +# Application deletion cannot be undone. Use carefully. +descope_client.mgmt.third_party_application.delete(app_id) +descope_client.mgmt.third_party_application.delete_batch([app_id]) +``` + +### Manage Lists + +Manage allow/deny lists of texts, IPs, or arbitrary JSON. + +```python +# Create a list. list_type is one of "texts", "ips", or "json". +resp = descope_client.mgmt.list.create( + name="blocked-ips", + list_type="ips", + description="Blocked addresses", # Optional + data=["1.2.3.0/24"], # Optional initial data +) +list_id = resp["list"]["id"] + +# Load +descope_client.mgmt.list.load(list_id) +descope_client.mgmt.list.load_by_name("blocked-ips") +descope_client.mgmt.list.load_all() + +# Update / delete (deletion cannot be undone) +descope_client.mgmt.list.update(id=list_id, name="blocked-ips", description="updated") +descope_client.mgmt.list.delete(list_id) + +# Import multiple lists at once +descope_client.mgmt.list.import_lists([{"name": "deny", "type": "texts", "data": ["foo"]}]) + +# IP lists +descope_client.mgmt.list.add_ips(list_id, ["10.0.0.1"]) +descope_client.mgmt.list.remove_ips(list_id, ["10.0.0.1"]) +is_blocked = descope_client.mgmt.list.check_ip(list_id, "10.0.0.1") + +# Text lists +descope_client.mgmt.list.add_texts(list_id, ["spam@example.com"]) +descope_client.mgmt.list.remove_texts(list_id, ["spam@example.com"]) +has_text = descope_client.mgmt.list.check_text(list_id, "spam@example.com") + +# Clear all entries +descope_client.mgmt.list.clear(list_id) +``` + +### Manage Password Settings + +Get and configure the password policy for the project or a specific tenant. + +```python +# Get password settings (omit tenant_id for project-level settings) +settings = descope_client.mgmt.password.get_settings() +tenant_settings = descope_client.mgmt.password.get_settings("tenant-id") + +# Configure settings. The settings dict mirrors the fields returned by get_settings, +# e.g. {"minLength": 8, "expiration": True, "expirationWeeks": 4, "reuse": True, "reuseAmount": 6} +descope_client.mgmt.password.configure_settings( + tenant_id="tenant-id", + settings={"minLength": 10, "nonAlphanumeric": True, "number": True, "uppercase": True}, +) +``` + +### Analytics + +Search analytics records, optionally filtered and grouped. + +```python +from datetime import datetime, timedelta + +results = descope_client.mgmt.analytics.search( + from_ts=datetime.now() - timedelta(days=7), # Optional, no older than 12 months + methods=["password", "otp"], # Optional auth methods + geos=["US", "IL"], # Optional country codes + group_by_action=True, # Optional grouping flags +) +``` + +### Manage Scope Claim Mapping + +Map OAuth/OIDC scopes to JWT claims for the project. + +```python +# Get the current mappings +mappings = descope_client.mgmt.scope_claim_mapping.get() + +# Set replaces all existing mappings. Each entry: {"scope": str, "claims": dict, "description": str} +descope_client.mgmt.scope_claim_mapping.set( + mappings=[{"scope": "profile", "claims": {"name": "name"}, "description": "Profile scope"}], +) + +# Delete all mappings +descope_client.mgmt.scope_claim_mapping.delete() +``` + ### Manage Descopers You can create, update, delete, load or list Descopers (users who have access to the Descope console): diff --git a/descope/management/user.py b/descope/management/user.py index 143b77a71..4a1405206 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -1,3 +1,4 @@ +import base64 from typing import List, Optional, Union from descope._http_base import HTTPBase @@ -1899,9 +1900,9 @@ def import_users( "dryrun": dryrun, } if users is not None: - body["users"] = users + body["users"] = base64.b64encode(users).decode("utf-8") if hashes is not None: - body["hashes"] = hashes + body["hashes"] = base64.b64encode(hashes).decode("utf-8") response = self._http.post( MgmtV1.user_import_path, diff --git a/descope/management/user_async.py b/descope/management/user_async.py index 2f840a4fb..ec850b192 100644 --- a/descope/management/user_async.py +++ b/descope/management/user_async.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 from typing import List, Optional, Union from descope._http_base import AsyncHTTPBase @@ -1907,9 +1908,9 @@ async def import_users( "dryrun": dryrun, } if users is not None: - body["users"] = users + body["users"] = base64.b64encode(users).decode("utf-8") if hashes is not None: - body["hashes"] = hashes + body["hashes"] = base64.b64encode(hashes).decode("utf-8") response = await self._http.post( MgmtV1.user_import_path, diff --git a/tests/management/test_user.py b/tests/management/test_user.py index e30dba9ed..d907d336e 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -2653,8 +2653,8 @@ async def test_import_users(self, client_factory): params=None, json={ "source": "auth0", - "users": b'{"users":[]}', - "hashes": b'{"hashes":[]}', + "users": "eyJ1c2VycyI6W119", + "hashes": "eyJoYXNoZXMiOltdfQ==", "dryrun": True, }, follow_redirects=False, From 4d547e9b432ae9a1b46935b6605970f721f33d1a Mon Sep 17 00:00:00 2001 From: Doron Sharon Date: Mon, 29 Jun 2026 18:50:52 +0000 Subject: [PATCH 3/3] test(management): cover third-party-app patch and search_consents branches Coverage dipped below the 98% gate because the patch optional-field assignments and the search_consents consent_id branch were never exercised. Pass every optional field in test_patch and add consent_id to test_search_consents so both sync and async paths are covered. Co-Authored-By: Claude Opus 4.8 --- .../test_third_party_application.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/management/test_third_party_application.py b/tests/management/test_third_party_application.py index cbd8dd838..1d97b7520 100644 --- a/tests/management/test_third_party_application.py +++ b/tests/management/test_third_party_application.py @@ -120,7 +120,16 @@ async def test_patch(self, client_factory): client.mgmt.third_party_application.patch( id="app1", name="patched-app", + login_page_url="https://login.example.com", + description="patched description", + logo="logo-data", + approved_callback_urls=["https://cb.example.com"], + permissions_scopes=[{"name": "read", "description": "Read", "values": ["roleA"]}], + attributes_scopes=[{"name": "email", "description": "Email", "values": ["email"]}], + jwt_bearer_settings={"audience": "aud"}, + custom_attributes={"team": "blue"}, force_pkce=False, + default_audience="my-audience", ) ) assert_http_called( @@ -136,7 +145,16 @@ async def test_patch(self, client_factory): json={ "id": "app1", "name": "patched-app", + "loginPageUrl": "https://login.example.com", + "description": "patched description", + "logo": "logo-data", + "approvedCallbackUrls": ["https://cb.example.com"], + "permissionsScopes": [{"name": "read", "description": "Read", "values": ["roleA"]}], + "attributesScopes": [{"name": "email", "description": "Email", "values": ["email"]}], + "jwtBearerSettings": {"audience": "aud"}, + "customAttributes": {"team": "blue"}, "forcePkce": False, + "defaultAudience": "my-audience", }, follow_redirects=False, ) @@ -393,6 +411,7 @@ async def test_search_consents(self, client_factory): client.mgmt.third_party_application.search_consents( app_id="app1", user_id="user1", + consent_id="c1", page=1, limit=10, tenant_id="tenant1", @@ -413,6 +432,7 @@ async def test_search_consents(self, client_factory): json={ "appId": "app1", "userId": "user1", + "consentId": "c1", "page": 1, "limit": 10, "tenantId": "tenant1",