diff --git a/README.md b/README.md index 4eb774f67..af42c40c7 100644 --- a/README.md +++ b/README.md @@ -700,6 +700,11 @@ descope_client.mgmt.user.invite( invite_url="invite.me" ) +# NOTE: `invite` only controls whether an invitation message is sent - it does NOT +# change the user's status. A newly invited user starts out in the "invited" status, +# not "enabled". If your use case requires the user to be active immediately, set +# `status="enabled"` explicitly via `create`/`patch`, or call `activate()` afterwards. + # Batch invite descope_client.mgmt.user.invite_batch( users=[ diff --git a/descope/management/_user_base.py b/descope/management/_user_base.py index 41d68cc7e..25f665844 100644 --- a/descope/management/_user_base.py +++ b/descope/management/_user_base.py @@ -9,6 +9,8 @@ ) from descope.management.user_pwd import UserPassword +VALID_USER_STATUSES = ["enabled", "disabled", "invited", "expired"] + class UserObj: def __init__( @@ -71,6 +73,16 @@ def __init__( class UserBase: + @staticmethod + def _validate_status(status: Optional[str], login_id: Optional[str] = None) -> None: + if status is not None and status not in VALID_USER_STATUSES: + suffix = f" for user {login_id}" if login_id is not None else "" + raise AuthException( + 400, + ERROR_TYPE_INVALID_ARGUMENT, + f"Invalid status value: {status}{suffix}. Must be one of: {', '.join(VALID_USER_STATUSES)}", + ) + @staticmethod def _compose_create_body( login_id: str, @@ -95,6 +107,7 @@ def _compose_create_body( sso_app_ids: Optional[List[str]] = None, template_id: str = "", locale: Optional[str] = None, + status: Optional[str] = None, ) -> dict: body = UserBase._compose_update_body( login_id=login_id, @@ -127,6 +140,8 @@ def _compose_create_body( body["templateId"] = template_id if locale is not None: body["locale"] = locale + if status is not None: + body["status"] = status return body @staticmethod diff --git a/descope/management/user.py b/descope/management/user.py index 0ecba20d3..c0d5e4fbd 100644 --- a/descope/management/user.py +++ b/descope/management/user.py @@ -32,6 +32,7 @@ def create( invite_url: Optional[str] = None, additional_login_ids: Optional[List[str]] = None, sso_app_ids: Optional[List[str]] = None, + status: Optional[str] = None, ) -> dict: """ Create a new user. Users can have any number of optional fields, including email, phone number and authorization. @@ -48,6 +49,7 @@ def create( picture (str): Optional url for user picture custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". Return value (dict): Return dict in the format @@ -57,6 +59,7 @@ def create( Raise: AuthException: raised if create operation fails """ + UserBase._validate_status(status) role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants @@ -83,6 +86,7 @@ def create( None, additional_login_ids, sso_app_ids, + status=status, ), ) return response.json() @@ -389,17 +393,7 @@ def patch( Raise: AuthException: raised if patch operation fails """ - if status is not None and status not in [ - "enabled", - "disabled", - "invited", - "expired", - ]: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Invalid status value: {status}. Must be one of: enabled, disabled, invited, expired", - ) + UserBase._validate_status(status) response = self._http.patch( MgmtV1.user_patch_path, body=UserBase._compose_patch_body( @@ -445,19 +439,8 @@ def patch_batch( Raise: AuthException: raised if patch batch operation fails """ - # Validate status fields for all users for user in users: - if user.status is not None and user.status not in [ - "enabled", - "disabled", - "invited", - "expired", - ]: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Invalid status value: {user.status} for user {user.login_id}. Must be one of: enabled, disabled, invited, expired", - ) + UserBase._validate_status(user.status, user.login_id) response = self._http.patch( MgmtV1.user_patch_batch_path, diff --git a/descope/management/user_async.py b/descope/management/user_async.py index 39dafff64..d7c9fcf54 100644 --- a/descope/management/user_async.py +++ b/descope/management/user_async.py @@ -36,6 +36,7 @@ async def create( invite_url: Optional[str] = None, additional_login_ids: Optional[List[str]] = None, sso_app_ids: Optional[List[str]] = None, + status: Optional[str] = None, ) -> dict: """ Create a new user. Users can have any number of optional fields, including email, phone number and authorization. @@ -52,6 +53,7 @@ async def create( picture (str): Optional url for user picture custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user. + status (str): Optional status field. Can be one of: "enabled", "disabled", "invited", "expired". Return value (dict): Return dict in the format @@ -61,6 +63,7 @@ async def create( Raise: AuthException: raised if create operation fails """ + UserBase._validate_status(status) role_names = [] if role_names is None else role_names user_tenants = [] if user_tenants is None else user_tenants @@ -87,6 +90,7 @@ async def create( None, additional_login_ids, sso_app_ids, + status=status, ), ) return response.json() @@ -393,17 +397,7 @@ async def patch( Raise: AuthException: raised if patch operation fails """ - if status is not None and status not in [ - "enabled", - "disabled", - "invited", - "expired", - ]: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Invalid status value: {status}. Must be one of: enabled, disabled, invited, expired", - ) + UserBase._validate_status(status) response = await self._http.patch( MgmtV1.user_patch_path, body=UserBase._compose_patch_body( @@ -451,17 +445,7 @@ async def patch_batch( """ # Validate status fields for all users for user in users: - if user.status is not None and user.status not in [ - "enabled", - "disabled", - "invited", - "expired", - ]: - raise AuthException( - 400, - ERROR_TYPE_INVALID_ARGUMENT, - f"Invalid status value: {user.status} for user {user.login_id}. Must be one of: enabled, disabled, invited, expired", - ) + UserBase._validate_status(user.status, user.login_id) response = await self._http.patch( MgmtV1.user_patch_batch_path, diff --git a/tests/management/test_user.py b/tests/management/test_user.py index 749a43502..955a57447 100644 --- a/tests/management/test_user.py +++ b/tests/management/test_user.py @@ -25,6 +25,10 @@ async def test_create(self, client_factory): with pytest.raises(AuthException): await client.invoke(client.mgmt.user.create("valid-id")) + with pytest.raises(AuthException) as exc_info: + await client.invoke(client.mgmt.user.create("valid-id", status="invalid_status")) + assert "Invalid status value: invalid_status" in str(exc_info.value) + # Test success flow with client.mock_mgmt_post(make_response({"user": {"id": "u1"}})) as mock_post: resp = await client.invoke( @@ -40,6 +44,7 @@ async def test_create(self, client_factory): custom_attributes={"ak": "av"}, additional_login_ids=["id-1", "id-2"], sso_app_ids=["app1", "app2"], + status="disabled", ) ) user = resp["user"] @@ -70,6 +75,7 @@ async def test_create(self, client_factory): "invite": False, "additionalLoginIds": ["id-1", "id-2"], "ssoAppIDs": ["app1", "app2"], + "status": "disabled", }, follow_redirects=False, )