Repository: zulip/zulip

Issue:
Improve error messages for unexpected users in subscribe/unsubscribe endpoints
As noted in https://github.com/zulip/zulip/pull/26183/commits/85fcc5e1f21675a4b281c32671ec41979adeab55#r1303446347, if you try to subscribe a deactivated user to a stream, you get a pretty weird error message. Here's the code responsible (used in the endpoints for editing another user's stream subscriptions):

```
class PrincipalError(JsonableError):                                                                    
    code = ErrorCode.UNAUTHORIZED_PRINCIPAL                                                             
    data_fields = ["principal"]                                                                         
    http_status_code = 403                                                                              
                                                                                                        
    def __init__(self, principal: Union[int, str]) -> None:                                             
        self.principal: Union[int, str] = principal                                                     
                                                                                                        
    @staticmethod                                                                                       
    def msg_format() -> str:                                                                            
        return _("User not authorized to execute queries on behalf of '{principal}'")                   
                                                                                                        
                                                                                                        
def principal_to_user_profile(agent: UserProfile, principal: Union[str, int]) -> UserProfile:           
    try:                                                                                                
        if isinstance(principal, str):                                                                  
            return get_active_user(principal, agent.realm)                                              
        else:                                                                                           
            return get_active_user_profile_by_id_in_realm(principal, agent.realm)                       
    except UserProfile.DoesNotExist:                                                                    
        # We have to make sure we don't leak information about which users                              
        # are registered for Zulip in a different realm.  We could do                                   
        # something a little more clever and check the domain part of the                               
        # principal to maybe give a better error message                                                
        raise PrincipalError(principal)                                                                 
```

I think probably `principal_to_user_profile` should get rewritten to use the `access_user_*` set of code paths, which already have better error messages for this class of issue.

@laurynmm it might be nice for you to take this issue, just because it's a bit of a thorny API/error handling issue, and it'd be nice if this were fixed close to when we fix #26183.


Discussion:
Hello @zulip/server-api members, this issue was labeled with the "area: api" label, so you may want to check it out!

<!-- areaLabelAddition -->


Resolving patch:
diff --git a/version.py b/version.py
--- a/version.py
+++ b/version.py
@@ -33,7 +33,7 @@
 # Changes should be accompanied by documentation explaining what the
 # new level means in api_docs/changelog.md, as well as "**Changes**"
 # entries in the endpoint's documentation in `zulip.yaml`.
-API_FEATURE_LEVEL = 207
+API_FEATURE_LEVEL = 208
 
 # Bump the minor PROVISION_VERSION to indicate that folks should provision
 # only when going from an old version of the code to a newer version. Bump
diff --git a/zerver/views/streams.py b/zerver/views/streams.py
--- a/zerver/views/streams.py
+++ b/zerver/views/streams.py
@@ -83,6 +83,7 @@
 )
 from zerver.lib.types import Validator
 from zerver.lib.user_groups import access_user_group_for_setting
+from zerver.lib.users import access_user_by_email, access_user_by_id
 from zerver.lib.utils import assert_is_not_none
 from zerver.lib.validator import (
     check_bool,
@@ -98,43 +99,18 @@
     check_union,
     to_non_negative_int,
 )
-from zerver.models import (
-    Realm,
-    Stream,
-    UserGroup,
-    UserMessage,
-    UserProfile,
-    get_active_user,
-    get_active_user_profile_by_id_in_realm,
-    get_system_bot,
-)
-
-
-class PrincipalError(JsonableError):
-    code = ErrorCode.UNAUTHORIZED_PRINCIPAL
-    data_fields = ["principal"]
-    http_status_code = 403
-
-    def __init__(self, principal: Union[int, str]) -> None:
-        self.principal: Union[int, str] = principal
-
-    @staticmethod
-    def msg_format() -> str:
-        return _("User not authorized to execute queries on behalf of '{principal}'")
+from zerver.models import Realm, Stream, UserGroup, UserMessage, UserProfile, get_system_bot
 
 
 def principal_to_user_profile(agent: UserProfile, principal: Union[str, int]) -> UserProfile:
-    try:
-        if isinstance(principal, str):
-            return get_active_user(principal, agent.realm)
-        else:
-            return get_active_user_profile_by_id_in_realm(principal, agent.realm)
-    except UserProfile.DoesNotExist:
-        # We have to make sure we don't leak information about which users
-        # are registered for Zulip in a different realm.  We could do
-        # something a little more clever and check the domain part of the
-        # principal to maybe give a better error message
-        raise PrincipalError(principal)
+    if isinstance(principal, str):
+        return access_user_by_email(
+            agent, principal, allow_deactivated=False, allow_bots=True, for_admin=False
+        )
+    else:
+        return access_user_by_id(
+            agent, principal, allow_deactivated=False, allow_bots=True, for_admin=False
+        )
 
 
 def user_directly_controls_user(user_profile: UserProfile, target: UserProfile) -> bool:


Test patch:
diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py
--- a/zerver/tests/test_subs.py
+++ b/zerver/tests/test_subs.py
@@ -2834,9 +2834,7 @@ def test_remove_invalid_user(self) -> None:
                 "principals": orjson.dumps([99]).decode(),
             },
         )
-        self.assert_json_error(
-            result, "User not authorized to execute queries on behalf of '99'", status_code=403
-        )
+        self.assert_json_error(result, "No such user", status_code=400)
 
 
 class DefaultStreamTest(ZulipTestCase):
@@ -5091,11 +5089,7 @@ def test_subscriptions_add_for_principal_deactivated(self) -> None:
         result = self.common_subscribe_to_streams(
             self.test_user, "Denmark", post_data, allow_fail=True
         )
-        self.assert_json_error(
-            result,
-            f"User not authorized to execute queries on behalf of '{target_profile.id}'",
-            status_code=403,
-        )
+        self.assert_json_error(result, "User is deactivated", status_code=400)
 
     def test_subscriptions_add_for_principal_invite_only(self) -> None:
         """
@@ -5138,11 +5132,7 @@ def test_subscription_add_invalid_principal_legacy_emails(self) -> None:
             {"principals": orjson.dumps([invalid_principal]).decode()},
             allow_fail=True,
         )
-        self.assert_json_error(
-            result,
-            f"User not authorized to execute queries on behalf of '{invalid_principal}'",
-            status_code=403,
-        )
+        self.assert_json_error(result, "No such user", status_code=400)
 
     def test_subscription_add_invalid_principal(self) -> None:
         invalid_principal = 999
@@ -5155,11 +5145,7 @@ def test_subscription_add_invalid_principal(self) -> None:
             {"principals": orjson.dumps([invalid_principal]).decode()},
             allow_fail=True,
         )
-        self.assert_json_error(
-            result,
-            f"User not authorized to execute queries on behalf of '{invalid_principal}'",
-            status_code=403,
-        )
+        self.assert_json_error(result, "No such user", status_code=400)
 
     def test_subscription_add_principal_other_realm(self) -> None:
         """
@@ -5176,11 +5162,7 @@ def test_subscription_add_principal_other_realm(self) -> None:
             {"principals": orjson.dumps([principal]).decode()},
             allow_fail=True,
         )
-        self.assert_json_error(
-            result,
-            f"User not authorized to execute queries on behalf of '{principal}'",
-            status_code=403,
-        )
+        self.assert_json_error(result, "No such user", status_code=400)
 
     def helper_check_subs_before_and_after_remove(
         self,