This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/allura.git

commit f5ad949b9cf3f6b943561a16e4cabcfce52188a6
Author: Dave Brondsema <[email protected]>
AuthorDate: Fri Jun 12 17:42:14 2026 -0400

    [#8607] perm checks on ticket bulk edit & move
---
 ForgeTracker/forgetracker/model/ticket.py           | 17 ++++++++++++++++-
 .../forgetracker/tests/functional/test_root.py      | 21 +++++++++++++++++++++
 2 files changed, 37 insertions(+), 1 deletion(-)

diff --git a/ForgeTracker/forgetracker/model/ticket.py 
b/ForgeTracker/forgetracker/model/ticket.py
index 17f143fe5..0434ba2b0 100644
--- a/ForgeTracker/forgetracker/model/ticket.py
+++ b/ForgeTracker/forgetracker/model/ticket.py
@@ -15,6 +15,7 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
+from __future__ import annotations
 import logging
 import six.moves.urllib.request
 import six.moves.urllib.parse
@@ -519,7 +520,11 @@ def changes_iter(user):
             count, 's' if count != 1 else '', app)
         Notification.post_user(c.user, None, 'flash', text=text)
 
-    def filtered_by_subscription(self, tickets, project_id=None, 
app_config_id=None):
+    def filtered_by_subscription(
+            self, tickets: dict[ObjectId, Ticket],
+            project_id: ObjectId | None = None,
+            app_config_id: ObjectId | None = None,
+    ) -> dict[ObjectId, set[ObjectId]]:  # user_id -> set of ticket_ids
         p_id = project_id if project_id else c.project._id
         ac_id = app_config_id if app_config_id else self.app_config_id
         ticket_ids = list(tickets.keys())
@@ -537,6 +542,16 @@ def filtered_by_subscription(self, tickets, 
project_id=None, app_config_id=None)
             elif subscription.artifact_index_id in 
list(tickets_index_id.keys()):
                 user = filtered.setdefault(subscription.user_id, set())
                 user.add(tickets_index_id[subscription.artifact_index_id])
+        # Check for subscribers who can't read them (e.g. private tickets 
reached via a tool-wide subscription)
+        users_by_id = {u._id: u for u in User.query.find({'_id': {'$in': 
list(filtered.keys())}})}
+        for user_id, t_ids in list(filtered.items()):
+            user = users_by_id.get(user_id)
+            readable = {t_id for t_id in t_ids
+                        if user and security.has_access(tickets[t_id], 'read', 
user)}
+            if readable:
+                filtered[user_id] = readable
+            else:
+                del filtered[user_id]
         return filtered
 
     def append_new_labels(self, old_labels, new_labels):
diff --git a/ForgeTracker/forgetracker/tests/functional/test_root.py 
b/ForgeTracker/forgetracker/tests/functional/test_root.py
index b2a57b524..f64acd9b3 100644
--- a/ForgeTracker/forgetracker/tests/functional/test_root.py
+++ b/ForgeTracker/forgetracker/tests/functional/test_root.py
@@ -1989,6 +1989,27 @@ def test_filtered_by_subscription(self):
         assert filtered_changes[users[1]._id] == set(ticket_ids[:-1])
         assert filtered_changes[admin._id] == set(ticket_ids[:-1])
 
+    def test_filtered_by_subscription_respects_private_read_access(self):
+        self.new_ticket(summary='public ticket', status='open')
+        self.new_ticket(summary='private ticket', status='open')
+        public = tm.Ticket.query.get(summary='public ticket')
+        private = tm.Ticket.query.get(summary='private ticket')
+        private.private = True
+        user = M.User.by_username('test-user-0')
+        admin = M.User.by_username('test-admin')
+        # tool-wide subscription: would otherwise receive every ticket change
+        M.Mailbox.subscribe(user_id=user._id, artifact=None)
+        ThreadLocalODMSession.flush_all()
+        M.MonQTask.run_ready()
+        ThreadLocalODMSession.flush_all()
+
+        changes = {public._id: public, private._id: private}
+        filtered = c.app.globals.filtered_by_subscription(changes)
+        # user can't read the private ticket, so it's excluded from their 
fanout
+        assert filtered[user._id] == {public._id}
+        # admin (developer/creator) can read both
+        assert filtered[admin._id] == {public._id, private._id}
+
     def test_vote(self):
         r = self.new_ticket(summary='test vote').follow()
         assert r.html.find('div', {'id': 'vote'})

Reply via email to