Repository: zulip/zulip
Pull request #34423: attachments: Allow seeing attachments to users with content access.
Author: login: shubham-padia
Labels: name: size: XL
Created: 2025-04-17T11:17:10Z | Merged: 2025-04-23T19:43:55Z

Fixes https://chat.zulip.org/#narrow/channel/9-issues/topic/Can't.20view.20images.20in.20private.20channel.2E


<!-- If the PR makes UI changes, always include one or more still screenshots to demonstrate your changes. If it seems helpful, add a screen capture of the new functionality as well.

Tooling tips: https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html
-->

**Screenshots and screen captures:**

<img width="640" alt="Screenshot 2025-04-17 at 4 46 14 PM" src="https://github.com/user-attachments/assets/fab073b6-91b3-4a87-bea8-60996c8d4da3" />


<details>
<summary>Self-review checklist</summary>

<!-- Prior to submitting a PR, follow our step-by-step guide to review your own code:
https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code -->

<!-- Once you create the PR, check off all the steps below that you have completed.
If any of these steps are not relevant or you have not completed, leave them unchecked.-->

- [ ] [Self-reviewed](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code) the changes for clarity and maintainability
      (variable names, code reuse, readability, etc.).

Communicate decisions, questions, and potential concerns.

- [ ] Explains differences from previous plans (e.g., issue description).
- [ ] Highlights technical choices and bugs encountered.
- [ ] Calls out remaining decisions and concerns.
- [ ] Automated tests verify logic where appropriate.

Individual commits are ready for review (see [commit discipline](https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html)).

- [ ] Each commit is a coherent idea.
- [ ] Commit message(s) explain reasoning and motivation for changes.

Completed manual review and testing of the following:

- [ ] Visual appearance of the changes.
- [ ] Responsiveness and internationalization.
- [ ] Strings and tooltips.
- [ ] End-to-end functionality of buttons, interactions and flows.
- [ ] Corner cases, error conditions, and easily imagined bugs.
</details>



Diff:
diff --git a/zerver/lib/attachments.py b/zerver/lib/attachments.py
index 32502561e44ed..02e58210eebdf 100644
--- a/zerver/lib/attachments.py
+++ b/zerver/lib/attachments.py
@@ -8,7 +8,9 @@
 from django.utils.translation import gettext as _
 
 from zerver.lib.exceptions import JsonableError, RateLimitedError
+from zerver.lib.streams import is_user_in_groups_granting_content_access
 from zerver.lib.upload import delete_message_attachment
+from zerver.lib.user_groups import get_recursive_membership_groups
 from zerver.models import (
     ArchivedAttachment,
     Attachment,
@@ -120,7 +122,7 @@ def validate_attachment_request(
         )
         attachment.refresh_from_db()
 
-    if user_profile == attachment.owner:
+    if user_profile.id == attachment.owner_id:
         # If you own the file, you can access it.
         return True, attachment
     if (
@@ -131,7 +133,7 @@ def validate_attachment_request(
         # Any user in the realm can access realm-public files
         return True, attachment
 
-    messages = attachment.messages.all()
+    messages = attachment.messages.all().select_related("recipient")
 
     usermessages_channel_ids = set()
     usermessage_rows = UserMessage.objects.filter(
@@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(subscribed_channel_ids):
         # If the attachment was sent in a channel with public
         # or protected history and the user is still subscribed
         # to the channel then anyone who received that message
         # can access it.
         return True, attachment
 
-    # The user didn't receive any of the messages that included this
-    # attachment. But they might still have access to it, if it was
-    # sent to a stream they are on where history is public to
-    # subscribers.
-    if len(relevant_channel_ids) == 0:
-        return False, attachment
-
-    return Stream.objects.filter(
-        id__in=relevant_channel_ids, history_public_to_subscribers=True
-    ).exists(), attachment
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    if len(message_channel_ids) > 0:
+        message_channels = Stream.objects.filter(id__in=message_channel_ids)
+        for channel in message_channels:
+            # The user didn't receive any of the messages that included
+            # this attachment. But they might still have access to it,
+            # if it was sent to a stream they are subscribed to where
+            # history is public to subscribers.
+            if channel.id in subscribed_channel_ids and channel.is_history_public_to_subscribers():
+                return True, attachment
+
+        user_recursive_group_ids = set(
+            get_recursive_membership_groups(user_profile).values_list("id", flat=True)
+        )
+        for channel in message_channels:
+            if is_user_in_groups_granting_content_access(channel, user_recursive_group_ids):
+                if channel.is_history_public_to_subscribers():
+                    return True, attachment
+                # If the user had received the message at one point of
+                # time, but they are no longer subscribed to a stream
+                # with protected history. They can still access that
+                # message and it's attachment
+                elif channel.id in usermessages_channel_ids:
+                    return True, attachment
+
+    return False, attachment
 
 
 def get_old_unclaimed_attachments(
diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py
index 06576e0c0ee68..4c0dbfd3b790e 100644
--- a/zerver/tests/test_subs.py
+++ b/zerver/tests/test_subs.py
@@ -147,6 +147,16 @@ def fix_expected_fields_for_stream_group_settings(expected_fields: set[str]) ->
     return expected_fields
 
 
+def check_subscriptions_exists(user_profile: UserProfile, stream: Stream) -> bool:
+    return (
+        get_active_subscriptions_for_stream_id(stream.id, include_deactivated_users=True)
+        .filter(
+            user_profile=user_profile,
+        )
+        .exists()
+    )
+
+
 class TestMiscStuff(ZulipTestCase):
     def test_test_helper(self) -> None:
         cordelia = self.example_user("cordelia")
@@ -1696,7 +1706,9 @@ def test_stream_permission_changes_updates_updates_attachments(self) -> None:
         self.assertTrue(attachment.is_realm_public)
 
         # Verify moving a message to a private stream
-        private_stream = self.make_stream("private_stream", realm=realm, invite_only=True)
+        private_stream = self.make_stream(
+            "private_stream", realm=realm, invite_only=True, history_public_to_subscribers=True
+        )
         self.subscribe(owner, "private_stream")
         result = self.client_patch(
             "/json/messages/" + str(msg_id),
@@ -1733,6 +1745,135 @@ def test_stream_permission_changes_updates_updates_attachments(self) -> None:
         attachment.refresh_from_db()
         self.assertTrue(attachment.is_web_public)
 
+    def test_stream_group_permission_changes_updates_updates_attachments(self) -> None:
+        self.login("desdemona")
+        fp = StringIO("zulip!")
+        fp.name = "zulip.txt"
+
+        result = self.client_post("/json/user_uploads", {"file": fp})
+        url = self.assert_json_success(result)["url"]
+
+        owner = self.example_user("desdemona")
+        realm = owner.realm
+        cordelia = self.example_user("cordelia")
+        private_stream_public_history = self.make_stream(
+            "private_stream_public_history",
+            realm=realm,
+            invite_only=True,
+            history_public_to_subscribers=True,
+        )
+        self.subscribe(owner, "private_stream_public_history")
+        body = f"First message ...[zulip.txt](http://{realm.host}" + url + ")"
+        msg_id = self.send_stream_message(owner, "private_stream_public_history", body, "test")
+        attachment = Attachment.objects.get(messages__id=msg_id)
+
+        self.assertFalse(private_stream_public_history.is_web_public)
+        self.assertFalse(attachment.is_web_public)
+        self.assertTrue(private_stream_public_history.invite_only)
+        self.assertFalse(attachment.is_realm_public)
+
+        # User should be able to see the attachment if they have
+        # content access to a channel with public history.
+        for setting_name in Stream.stream_permission_group_settings_requiring_content_access:
+            self.assertFalse(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            do_change_stream_group_based_setting(
+                private_stream_public_history,
+                setting_name,
+                UserGroupMembersData(direct_members=[cordelia.id], direct_subgroups=[]),
+                acting_user=cordelia,
+            )
+            self.assertFalse(check_subscriptions_exists(cordelia, private_stream_public_history))
+            self.assertTrue(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            attachment.refresh_from_db()
+            self.assertFalse(attachment.is_realm_public)
+            nobody_group = NamedUserGroup.objects.get(
+                name="role:nobody", is_system_group=True, realm=realm
+            )
+            do_change_stream_group_based_setting(
+                private_stream_public_history, setting_name, nobody_group, acting_user=cordelia
+            )
+
+        fp = StringIO("zulip2!")
+        fp.name = "zulip2.txt"
+
+        result = self.client_post("/json/user_uploads", {"file": fp})
+        url = self.assert_json_success(result)["url"]
+
+        private_stream_protected_history = self.make_stream(
+            "private_stream_protected_history",
+            realm=realm,
+            invite_only=True,
+            history_public_to_subscribers=False,
+        )
+        self.subscribe(owner, "private_stream_protected_history")
+        body = f"First message ...[zulip2.txt](http://{realm.host}" + url + ")"
+        msg_id = self.send_stream_message(owner, "private_stream_protected_history", body, "test")
+        attachment = Attachment.objects.get(messages__id=msg_id)
+
+        # User should not be able to see the attachment if they have
+        # content access to a private channel with protected history
+        # but were not subscribed to the channel when the upload was
+        # sent.
+        for setting_name in Stream.stream_permission_group_settings_requiring_content_access:
+            self.assertFalse(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            do_change_stream_group_based_setting(
+                private_stream_protected_history,
+                setting_name,
+                UserGroupMembersData(direct_members=[cordelia.id], direct_subgroups=[]),
+                acting_user=cordelia,
+            )
+            self.assertFalse(check_subscriptions_exists(cordelia, private_stream_protected_history))
+            self.assertFalse(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+
+            # They should not have access to the upload if they are
+            # subscribed to the channel but they were not subscribed at
+            # the time of upload.
+            self.subscribe(cordelia, "private_stream_protected_history")
+            self.assertFalse(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            self.unsubscribe(cordelia, "private_stream_protected_history")
+            attachment.refresh_from_db()
+
+            nobody_group = NamedUserGroup.objects.get(
+                name="role:nobody", is_system_group=True, realm=realm
+            )
+            do_change_stream_group_based_setting(
+                private_stream_protected_history, setting_name, nobody_group, acting_user=cordelia
+            )
+
+        # User should be able to see the attachment if they have
+        # content access to a private channel with protected history
+        # and were subscribed to the channel when the upload was sent.
+        self.subscribe(cordelia, "private_stream_protected_history")
+        body = f"Second message ...[zulip2.txt](http://{realm.host}" + url + ")"
+        msg_id = self.send_stream_message(owner, "private_stream_protected_history", body, "test")
+        attachment = Attachment.objects.get(messages__id=msg_id)
+        self.unsubscribe(cordelia, "private_stream_protected_history")
+        for setting_name in Stream.stream_permission_group_settings_requiring_content_access:
+            self.assertFalse(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            do_change_stream_group_based_setting(
+                private_stream_protected_history,
+                setting_name,
+                UserGroupMembersData(direct_members=[cordelia.id], direct_subgroups=[]),
+                acting_user=cordelia,
+            )
+            self.assertFalse(check_subscriptions_exists(cordelia, private_stream_protected_history))
+            self.assertTrue(validate_attachment_request(cordelia, attachment.path_id)[0])
+            self.assertTrue(validate_attachment_request(owner, attachment.path_id)[0])
+            attachment.refresh_from_db()
+            self.assertFalse(attachment.is_realm_public)
+            nobody_group = NamedUserGroup.objects.get(
+                name="role:nobody", is_system_group=True, realm=realm
+            )
+            do_change_stream_group_based_setting(
+                private_stream_protected_history, setting_name, nobody_group, acting_user=cordelia
+            )
+
     def test_try_make_stream_public_with_private_history(self) -> None:
         # We only support public streams with private history if
         # is_zephyr_mirror_realm, and don't allow changing stream
@@ -1818,14 +1959,7 @@ def test_deactivate_stream_as_realm_admin(self) -> None:
         self.subscribe(cordelia, stream.name)
         result = self.client_delete(f"/json/streams/{stream.id}")
         self.assert_json_success(result)
-        subscription_exists = (
-            get_active_subscriptions_for_stream_id(stream.id, include_deactivated_users=True)
-            .filter(
-                user_profile=user_profile,
-            )
-            .exists()
-        )
-        self.assertTrue(subscription_exists)
+        self.assertTrue(check_subscriptions_exists(user_profile, stream))
         # Assert that a notification message was sent for the archive.
         message = self.get_last_message()
         expected_content = f"Channel {stream.name} has been archived."
@@ -1855,14 +1989,7 @@ def test_deactivate_stream_via_user_group_permissions(self) -> None:
         )
         result = self.client_delete(f"/json/streams/{stream.id}")
         self.assert_json_success(result)
-        subscription_exists = (
-            get_active_subscriptions_for_stream_id(stream.id, include_deactivated_users=True)
-            .filter(
-                user_profile=user_profile,
-            )
-            .exists()
-        )
-        self.assertTrue(subscription_exists)
+        self.assertTrue(check_subscriptions_exists(user_profile, stream))
         # Assert that a notification message was sent for the archive.
         message = self.get_last_message()
         expected_content = f"Channel {stream.name} has been archived."
diff --git a/zerver/tests/test_upload.py b/zerver/tests/test_upload.py
index 6da2a59ac880a..9d45bc7805aa3 100644
--- a/zerver/tests/test_upload.py
+++ b/zerver/tests/test_upload.py
@@ -803,7 +803,7 @@ def test_file_download_authorization_invite_only(self) -> None:
 
         # Owner user should be able to view file
         self.login_user(hamlet)
-        with self.assert_database_query_count(6):
+        with self.assert_database_query_count(5):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
@@ -811,7 +811,7 @@ def test_file_download_authorization_invite_only(self) -> None:
 
         # Subscribed user who received the message should be able to view file
         self.login_user(cordelia)
-        with self.assert_database_query_count(9):
+        with self.assert_database_query_count(8):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
@@ -864,7 +864,7 @@ def test_file_download_authorization_invite_only_with_shared_history(self) -> No
 
         # Owner user should be able to view file
         self.login_user(user)
-        with self.assert_database_query_count(6):
+        with self.assert_database_query_count(5):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
@@ -872,7 +872,7 @@ def test_file_download_authorization_invite_only_with_shared_history(self) -> No
 
         # Originally subscribed user should be able to view file
         self.login_user(polonius)
-        with self.assert_database_query_count(9):
+        with self.assert_database_query_count(8):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
@@ -880,7 +880,7 @@ def test_file_download_authorization_invite_only_with_shared_history(self) -> No
 
         # Subscribed user who did not receive the message should also be able to view file
         self.login_user(late_subscribed_user)
-        with self.assert_database_query_count(10):
+        with self.assert_database_query_count(9):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
@@ -890,7 +890,7 @@ def test_file_download_authorization_invite_only_with_shared_history(self) -> No
         def assert_cannot_access_file(user: UserProfile) -> None:
             self.login_user(user)
             # It takes a few extra queries to verify lack of access with shared history.
-            with self.assert_database_query_count(9):
+            with self.assert_database_query_count(10):
                 response = self.client_get(url)
             self.assertEqual(response.status_code, 403)
             self.assert_in_response("You are not authorized to view this file.", response)
@@ -931,7 +931,7 @@ def test_multiple_message_attachment_file_download(self) -> None:
 
         user = self.example_user("aaron")
         self.login_user(user)
-        with self.assert_database_query_count(9):
+        with self.assert_database_query_count(10):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 403)
             self.assert_in_response("You are not authorized to view this file.", response)
@@ -940,12 +940,12 @@ def test_multiple_message_attachment_file_download(self) -> None:
         self.subscribe(user, "test-subscribe 2")
 
         # If we were accidentally one query per message, this would be 20+
-        with self.assert_database_query_count(10):
+        with self.assert_database_query_count(9):
             response = self.client_get(url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.getvalue(), b"zulip!")
 
-        with self.assert_database_query_count(6):
+        with self.assert_database_query_count(5):
             self.assertTrue(validate_attachment_request(user, fp_path_id)[0])
 
         self.logout()


Reviews:
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:14:39Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:18:30Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T18:23:34Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T12:33:32Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T12:35:59Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T12:42:35Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T12:43:04Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T12:47:52Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T08:24:55Z
user: login: andersk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T18:44:10Z
user: login: andersk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T18:44:34Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T11:52:20Z
user: login: shubham-padia
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T11:52:25Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T19:10:41Z

Review comments:
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
+    message_channels_with_public_history = Stream.objects.filter(
+        id__in=message_channel_ids, history_public_to_subscribers=True
+    )
path: zerver/lib/attachments.py
user: login: timabbott
body: I think if you do `message.recipient_id` and `recipient_id__in=message_recipient_ids`, you can avoid needing to `.select_related("recipient")`.

It might need a bit more refactoring to work, but I think the backstory is that the existing code was just not taking advantage of the denormalization we did to make `Stream.recipient` exist.
created_at: 2025-04-17T18:14:39Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
+    message_channels_with_public_history = Stream.objects.filter(
+        id__in=message_channel_ids, history_public_to_subscribers=True
+    )
+    if len(message_channels_with_public_history) > 0:
+        user_recursive_group_ids = set(
+            get_recursive_membership_groups(user_profile).values_list("id", flat=True)
+        )
+        for channel in message_channels_with_public_history:
+            if is_user_in_groups_granting_content_access(channel, user_recursive_group_ids):
+                return True, attachment
+
path: zerver/lib/attachments.py
user: login: timabbott
body: Accessing uploaded files is a very hot code path, so I think it is probably worth more attention to optimize it. Here, you're treating "subscriber" differently from "has content" access in a way that probably results in duplicated work.

I think probably instead we want to just make the `relevant_channel_ids` calculation include channels accessible via content access, not just active Subscriptions.
created_at: 2025-04-17T18:18:30Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
path: zerver/lib/attachments.py
user: login: timabbott
body: Hmm, is that exactly how it works? Or can groups grant content access to previously received messages in a protected history channel?
created_at: 2025-04-17T18:23:34Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
+    message_channels_with_public_history = Stream.objects.filter(
+        id__in=message_channel_ids, history_public_to_subscribers=True
+    )
path: zerver/lib/attachments.py
user: login: shubham-padia
body: I tried this, it does not make a difference on the query count, since we will ultimately need to fetch the recipients when fetching subscriptions. A separate recipient query is called only once in the query logs as seen here: https://pastebin.com/Ltq30jgS
created_at: 2025-04-18T12:33:32Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
+    message_channels_with_public_history = Stream.objects.filter(
+        id__in=message_channel_ids, history_public_to_subscribers=True
+    )
+    if len(message_channels_with_public_history) > 0:
+        user_recursive_group_ids = set(
+            get_recursive_membership_groups(user_profile).values_list("id", flat=True)
+        )
+        for channel in message_channels_with_public_history:
+            if is_user_in_groups_granting_content_access(channel, user_recursive_group_ids):
+                return True, attachment
+
path: zerver/lib/attachments.py
user: login: shubham-padia
body: I've modified the code, we now check the scenario of access only through groups (not subscribed) at the very end and only fetch `user_recursive_group_ids` if necessary, which it will not be in most cases. Not sure if it would make sense for `relevant_channel_ids` to have all content accessible channels in that case. 
created_at: 2025-04-18T12:35:59Z
author_association: MEMBER
diff_hunk: @@ -890,7 +890,7 @@ def test_file_download_authorization_invite_only_with_shared_history(self) -> No
         def assert_cannot_access_file(user: UserProfile) -> None:
             self.login_user(user)
             # It takes a few extra queries to verify lack of access with shared history.
-            with self.assert_database_query_count(9):
+            with self.assert_database_query_count(10):
path: zerver/tests/test_upload.py
user: login: shubham-padia
body: 9 -> 8 (owner_id change)

8 -> 10 

```
#9

sql: SELECT "zerver_stream"."id", "zerver_stream"."name", "zerver_stream"."realm_id", "zerver_stream"."date_created", "zerver_stream"."creator_id", "zerver_stream"."deactivated", "zerver_stream"."description", "zerver_stream"."rendered_description", "zerver_stream"."recipient_id", "zerver_stream"."invite_only", "zerver_stream"."history_public_to_subscribers", "zerver_stream"."is_web_public", "zerver_stream"."is_in_zephyr_realm", "zerver_stream"."message_retention_days", "zerver_stream"."can_add_subscribers_group_id", "zerver_stream"."can_administer_channel_group_id", "zerver_stream"."can_remove_subscribers_group_id", "zerver_stream"."can_send_message_group_id", "zerver_stream"."can_subscribe_group_id", "zerver_stream"."first_message_id", "zerver_stream"."is_recently_active" FROM "zerver_stream" WHERE ("zerver_stream"."history_public_to_subscribers" AND "zerver_stream"."recipient_id" IN (140, 141, 142, 143, 144))

time: 0.000



#10

sql: WITH RECURSIVE "cte" AS ((SELECT "zerver_usergroup"."id" AS "group_id" FROM "zerver_usergroup" INNER JOIN "zerver_usergroupmembership" ON ("zerver_usergroup"."id" = "zerver_usergroupmembership"."user_group_id") WHERE "zerver_usergroupmembership"."user_profile_id" = 6) UNION (SELECT "zerver_usergroup"."id" AS "group_id" FROM "zerver_usergroup" INNER JOIN "zerver_groupgroupmembership" ON ("zerver_usergroup"."id" = "zerver_groupgroupmembership"."supergroup_id") INNER JOIN "cte" ON "zerver_groupgroupmembership"."subgroup_id" = ("cte"."group_id"))) SELECT "zerver_usergroup"."id" FROM "zerver_usergroup" INNER JOIN "cte" ON "zerver_usergroup"."id" = ("cte"."group_id")

time: 0.001
```

One query to fetch streams and one for recursive group ids, not sure if we could avoid either.
created_at: 2025-04-18T12:42:35Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
path: zerver/lib/attachments.py
user: login: shubham-padia
body: Yes, you are right, fixed!
created_at: 2025-04-18T12:43:04Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
path: zerver/lib/attachments.py
user: login: shubham-padia
body: TODO: I need to add a test for this
created_at: 2025-04-18T12:47:52Z
author_association: MEMBER
diff_hunk: @@ -160,6 +162,24 @@ def validate_attachment_request(
         # can access it.
         return True, attachment
 
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    # We only fetch channels with public history, since those are the
+    # only channels that user groups can grant content access to.
path: zerver/lib/attachments.py
user: login: shubham-padia
body: Done
created_at: 2025-04-21T08:24:55Z
author_association: MEMBER
diff_hunk: @@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(set(subscribed_channel_ids)):
path: zerver/lib/attachments.py
user: login: andersk
body: ```suggestion
    if usermessages_channel_ids.intersection(subscribed_channel_ids):
```
created_at: 2025-04-21T18:44:09Z
author_association: MEMBER
diff_hunk: @@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(set(subscribed_channel_ids)):
         # If the attachment was sent in a channel with public
         # or protected history and the user is still subscribed
         # to the channel then anyone who received that message
         # can access it.
         return True, attachment
 
-    # The user didn't receive any of the messages that included this
-    # attachment. But they might still have access to it, if it was
-    # sent to a stream they are on where history is public to
-    # subscribers.
-    if len(relevant_channel_ids) == 0:
-        return False, attachment
-
-    return Stream.objects.filter(
-        id__in=relevant_channel_ids, history_public_to_subscribers=True
-    ).exists(), attachment
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    if len(message_channel_ids) > 0:
+        message_channels = Stream.objects.filter(id__in=message_channel_ids)
+        for channel in message_channels:
+            # The user didn't receive any of the messages that included
+            # this attachment. But they might still have access to it,
+            # if it was sent to a stream they are subscribed to where
+            # history is public to subscribers.
+            if channel.id in subscribed_channel_ids and channel.is_history_public_to_subscribers():
+                return True, attachment
+
+        user_recursive_group_ids = set(
+            get_recursive_membership_groups(user_profile).values_list("id", flat=True)
+        )
+        for channel in message_channels:
+            if is_user_in_groups_granting_content_access(channel, user_recursive_group_ids):
+                if channel.is_history_public_to_subscribers():
+                    return True, attachment
+                # If the user had received the message at one point of
+                # time, but they are no longer subscribed to a stream
+                # with protected history. They can still access that
+                # message and it's attachment
+                elif usermessages_channel_ids.intersection({channel.id}):
path: zerver/lib/attachments.py
user: login: andersk
body: ```suggestion
                elif channel.id in usermessages_channel_ids:
```
created_at: 2025-04-21T18:44:34Z
author_association: MEMBER
diff_hunk: @@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(set(subscribed_channel_ids)):
         # If the attachment was sent in a channel with public
         # or protected history and the user is still subscribed
         # to the channel then anyone who received that message
         # can access it.
         return True, attachment
 
-    # The user didn't receive any of the messages that included this
-    # attachment. But they might still have access to it, if it was
-    # sent to a stream they are on where history is public to
-    # subscribers.
-    if len(relevant_channel_ids) == 0:
-        return False, attachment
-
-    return Stream.objects.filter(
-        id__in=relevant_channel_ids, history_public_to_subscribers=True
-    ).exists(), attachment
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    if len(message_channel_ids) > 0:
+        message_channels = Stream.objects.filter(id__in=message_channel_ids)
+        for channel in message_channels:
+            # The user didn't receive any of the messages that included
+            # this attachment. But they might still have access to it,
+            # if it was sent to a stream they are subscribed to where
+            # history is public to subscribers.
+            if channel.id in subscribed_channel_ids and channel.is_history_public_to_subscribers():
+                return True, attachment
+
+        user_recursive_group_ids = set(
+            get_recursive_membership_groups(user_profile).values_list("id", flat=True)
+        )
+        for channel in message_channels:
+            if is_user_in_groups_granting_content_access(channel, user_recursive_group_ids):
+                if channel.is_history_public_to_subscribers():
+                    return True, attachment
+                # If the user had received the message at one point of
+                # time, but they are no longer subscribed to a stream
+                # with protected history. They can still access that
+                # message and it's attachment
+                elif usermessages_channel_ids.intersection({channel.id}):
path: zerver/lib/attachments.py
user: login: shubham-padia
body: https://github.com/zulip/zulip/compare/2c38961495e8093ab309b89497bb027162818b8f..842686ec40898bdd08d86b19ece5cee8bcaa1a9a
created_at: 2025-04-22T11:52:20Z
author_association: MEMBER
diff_hunk: @@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(set(subscribed_channel_ids)):
path: zerver/lib/attachments.py
user: login: shubham-padia
body: https://github.com/zulip/zulip/compare/2c38961495e8093ab309b89497bb027162818b8f..842686ec40898bdd08d86b19ece5cee8bcaa1a9a
created_at: 2025-04-22T11:52:25Z
author_association: MEMBER
diff_hunk: @@ -146,30 +148,50 @@ def validate_attachment_request(
             usermessages_channel_ids.add(um.message.recipient.type_id)
 
     # These are subscriptions to a channel one of the messages was sent to
-    relevant_channel_ids = Subscription.objects.filter(
+    subscribed_channel_ids = Subscription.objects.filter(
         user_profile=user_profile,
         active=True,
         recipient__type=Recipient.STREAM,
         recipient__in=[m.recipient_id for m in messages],
     ).values_list("recipient__type_id", flat=True)
 
-    if usermessages_channel_ids & set(relevant_channel_ids):
+    if usermessages_channel_ids.intersection(subscribed_channel_ids):
         # If the attachment was sent in a channel with public
         # or protected history and the user is still subscribed
         # to the channel then anyone who received that message
         # can access it.
         return True, attachment
 
-    # The user didn't receive any of the messages that included this
-    # attachment. But they might still have access to it, if it was
-    # sent to a stream they are on where history is public to
-    # subscribers.
-    if len(relevant_channel_ids) == 0:
-        return False, attachment
-
-    return Stream.objects.filter(
-        id__in=relevant_channel_ids, history_public_to_subscribers=True
-    ).exists(), attachment
+    message_channel_ids = set()
+    for message in messages:
+        if message.is_stream_message():
+            message_channel_ids.add(message.recipient.type_id)
+
+    if len(message_channel_ids) > 0:
path: zerver/lib/attachments.py
user: login: timabbott
body: this might be better written with the early return pattern -- returning if it's 0. 
created_at: 2025-04-23T19:10:41Z
author_association: MEMBER

PR comments:
user: login: timabbott
created_at: 2025-04-23T19:22:48Z
body: I added some comments to help with long-term readability of the code here, since it is an important/hot code path, after making myself think through the implications and confirm that it is quite low risk for causing a material performance regression.

I did the small early return regression noted above, and this can merge once CI passes.
author_association: MEMBER
user: login: timabbott
created_at: 2025-04-24T00:11:40Z
body: Apparently I failed to commit my changes, so they're in https://github.com/zulip/zulip/pull/34423.
author_association: MEMBER
user: login: timabbott
created_at: 2025-05-07T22:38:39Z
body: Backported to 10.x in #34576.
author_association: MEMBER