details:   https://code.tryton.org/tryton/commit/aa9ae9c04e8c
branch:    default
user:      Nicolas Évrard <[email protected]>
date:      Fri Sep 11 17:53:07 2026 +0200
description:
        Use the default search implementation on ResourceAccessMixin

        The access to search is now guarded by a method allowing only 
administrator to
        access it.

        Closes #14817
diffstat:

 trytond/trytond/ir/message.xml         |    3 +
 trytond/trytond/ir/resource.py         |  107 ++++++++++++++-------------
 trytond/trytond/tests/test_resource.py |  126 +++++++++++++++++++++-----------
 3 files changed, 141 insertions(+), 95 deletions(-)

diffs (288 lines):

diff -r 6ebedcf8826e -r aa9ae9c04e8c trytond/trytond/ir/message.xml
--- a/trytond/trytond/ir/message.xml    Sun Sep 13 17:28:32 2026 +0200
+++ b/trytond/trytond/ir/message.xml    Fri Sep 11 17:53:07 2026 +0200
@@ -414,6 +414,9 @@
         <record model="ir.message" id="msg_access_report_error">
             <field name="text">You are not allowed to execute report 
"%(report)s".</field>
         </record>
+        <record model="ir.message" id="msg_access_resource_search_error">
+            <field name="text">You are not allowed to search on the 
"%(resource)s" resource.</field>
+        </record>
         <record model="ir.message" id="msg_email_template_invalid_subject">
             <field name="text">Invalid subject in email template 
"%(template)s" with exception "%(exception)s".</field>
         </record>
diff -r 6ebedcf8826e -r aa9ae9c04e8c trytond/trytond/ir/resource.py
--- a/trytond/trytond/ir/resource.py    Sun Sep 13 17:28:32 2026 +0200
+++ b/trytond/trytond/ir/resource.py    Fri Sep 11 17:53:07 2026 +0200
@@ -6,11 +6,12 @@
 from sql.conditionals import Coalesce
 from sql.functions import DateTrunc
 
-from trytond.i18n import lazy_gettext
-from trytond.model import (
-    Index, Model, ModelSQL, ModelStorage, ModelView, fields)
+from trytond.i18n import gettext, lazy_gettext
+from trytond.model import Index, ModelSQL, ModelStorage, ModelView, fields
+from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.pyson import Eval
+from trytond.tools.domain_inversion import is_leaf
 from trytond.transaction import Transaction, without_check_access
 
 __all__ = ['ResourceAccessMixin', 'ResourceMixin', 'resource_copy']
@@ -80,58 +81,60 @@
             (model, {'create': 'write', 'delete': 'write'}.get(mode, mode))]
 
     @classmethod
+    def check_search_access(cls, domain):
+        pool = Pool()
+        ModelAccess = pool.get('ir.model.access')
+        Rule = pool.get('ir.rule')
+        User = pool.get('res.user')
+
+        def check_ressource_in_domain(domain):
+            if not domain:
+                return False
+            elif domain[0] == 'OR':
+                return False
+            elif domain[0] == 'AND':
+                return check_ressource_in_domain(domain[1:])
+            elif is_leaf(domain):
+                return False
+
+            for expr in domain:
+                match expr:
+                    case ('resource', '=', resource):
+                        model, id_ = resource.split(',', 1)
+                        id_ = int(id_)
+                        ModelAccess.check(model, mode='read')
+                        Rule.check(model, [id_], mode='read')
+                        return True
+                    case ('resource', 'in', resources):
+                        models = defaultdict(list)
+                        for resource in resources:
+                            model, id_ = resource.split(',', 1)
+                            id_ = int(id_)
+                            models[model].append(id_)
+                        for model, ids in models.items():
+                            ModelAccess.check(model, mode='read')
+                            Rule.check(model, ids, mode='read')
+                        return True
+                    case _:
+                        if check_ressource_in_domain(expr):
+                            return True
+            return False
+
+        if (Transaction().check_access
+                and not User.is_administrator()
+                and not check_ressource_in_domain(domain)):
+            raise AccessError(gettext(
+                    'ir.msg_access_resource_search_error',
+                    resource=cls.__name__))
+
+    @classmethod
     def search(
             cls, domain, offset=0, limit=None, order=None, count=False,
             **kwargs):
-        transaction = Transaction()
-        enforce_access = (
-            not kwargs.get('query', False)
-            and transaction.user and transaction.check_access)
-        result = super().search(
-            domain, offset=offset, limit=limit, order=order,
-            count=False if enforce_access else count, **kwargs)
-        if not enforce_access:
-            return result
-
-        loop = 0
-        fetched = []
-        while True:
-            records = result
-            resources = defaultdict(set)
-            allowed = set()
-            with without_check_access():
-                records = cls.browse(records)
-            for record in records:
-                if isinstance(record.resource, Model):
-                    resources[record.resource.__class__].add(
-                        record.resource.id)
-
-            for RModel, ids in resources.items():
-                allowed.update(RModel.search([
-                            ('id', 'in', ids),
-                            ]))
-
-            fetched.extend([
-                r for r in records
-                if not r.resource or r.resource in allowed])
-
-            if limit is None or len(fetched) >= limit:
-                if limit is not None:
-                    fetched = fetched[:limit]
-                break
-
-            loop += 1
-            result = super().search(
-                domain, offset=offset + loop * limit, limit=limit, order=order,
-                count=False)
-            if not result:
-                break
-
-        if count:
-            return len(fetched)
-        else:
-            # re-browse to have same context
-            return cls.browse(fetched)
+        cls.check_search_access(domain)
+        return super().search(
+            domain, offset=offset, limit=limit, order=order, count=count,
+            **kwargs)
 
     @classmethod
     def read(cls, ids, fields_names):
diff -r 6ebedcf8826e -r aa9ae9c04e8c trytond/trytond/tests/test_resource.py
--- a/trytond/trytond/tests/test_resource.py    Sun Sep 13 17:28:32 2026 +0200
+++ b/trytond/trytond/tests/test_resource.py    Fri Sep 11 17:53:07 2026 +0200
@@ -79,31 +79,6 @@
         self.assertEqual(user_note.write_date, write_date)
 
     @with_transaction()
-    def test_resources_rule(self):
-        "Test resources rules are applied on search"
-        pool = Pool()
-        Note = pool.get('ir.note')
-        Warning = pool.get('res.user.warning')
-
-        warning1 = Warning(user=0, name="root")
-        warning1.save()
-        note1 = Note(resource=warning1)
-        note1.save()
-        warning2 = Warning(user=1, name="admin")
-        warning2.save()
-        note2 = Note(resource=warning2)
-        note2.save()
-
-        with Transaction().set_context(_check_access=True):
-            notes = Note.search([])
-            count = Note.search([], count=True)
-            query = Note.search([], query=True)
-
-            self.assertEqual(notes, [note2])
-            self.assertEqual(count, 1)
-            self.assertTrue(query)
-
-    @with_transaction()
     def test_resource_with_access(self):
         "Test create/write/read/delete on resource with access"
         pool = Pool()
@@ -251,27 +226,92 @@
             with self.assertRaises(AccessError):
                 Note.delete([note])
 
-    @with_transaction()
-    def test_resources_search_limit(self):
-        "Test resource search limit work as expected"
+    @with_transaction(context={'_check_access': True})
+    def test_resource_search_with_access(self):
+        "Test searching on the resource when the user has access"
         pool = Pool()
         Note = pool.get('ir.note')
-        Warning_ = pool.get('res.user.warning')
+        Resource = pool.get('test.resource')
+        ModelAccess = pool.get('ir.model.access')
+        User = pool.get('res.user')
+
+        user = User(login='foo')
+        user.save()
+        ModelAccess.create([{
+                    'model': Resource.__name__,
+                    'group': None,
+                    'perm_write': True,
+                    'perm_read': True,
+                    }])
+        record1, record2 = Resource.create([{}, {}])
+        note, = Note.create([{
+                    'resource': record1,
+                    'message': "Foo",
+                    }])
 
-        warning1 = Warning_(user=0, name="root")
-        warning1.save()
-        warning2 = Warning_(user=1, name="admin")
-        warning2.save()
-        for i in range(100):
-            note = Note(resource=warning2 if i % 3 else warning1)
-            note.save()
+        with Transaction().set_user(user.id):
+            notes = Note.search([
+                    ('message', '=', 'Foo'),
+                    ('resource', '=', str(record1)),
+                    ])
+            self.assertEqual([note], notes)
+
+            notes = Note.search([
+                    ('message', '=', 'Foo'),
+                    ('resource', 'in', [str(record1), str(record2)]),
+                    ])
+            self.assertEqual([note], notes)
+
+            notes = Note.search([
+                    ('message', '=', 'Foo'),
+                    ('resource', '=', str(record2)),
+                    ])
+            self.assertEqual([], notes)
+
+            with self.assertRaises(AccessError):
+                Note.search([('message', '=', 'Foo')])
 
-        with Transaction().set_context(_check_access=True):
-            notes = Note.search([], limit=10, offset=0)
-            self.assertEqual(len(notes), 10)
+    @with_transaction(context={'_check_access': True})
+    def test_resource_search_without_access(self):
+        "Test searching on the resource when the user doesn't have access"
+        pool = Pool()
+        Note = pool.get('ir.note')
+        Resource = pool.get('test.resource')
+        ModelAccess = pool.get('ir.model.access')
+        User = pool.get('res.user')
 
-            notes = Note.search([], limit=200, offset=0)
-            self.assertEqual(len(notes), 66)
+        user = User(login='foo')
+        user.save()
+        ModelAccess.create([{
+                    'model': Resource.__name__,
+                    'group': None,
+                    'perm_write': False,
+                    'perm_read': False,
+                    }])
+        record1, record2 = Resource.create([{}, {}])
+        note, = Note.create([{
+                    'resource': record1,
+                    'message': "Foo",
+                    }])
 
-            notes = Note.search([])
-            self.assertEqual(len(notes), 66)
+        with Transaction().set_user(user.id):
+            with self.assertRaises(AccessError):
+                Note.search([
+                        ('message', '=', 'Foo'),
+                        ('resource', '=', str(record1)),
+                        ])
+
+            with self.assertRaises(AccessError):
+                Note.search([
+                        ('message', '=', 'Foo'),
+                        ('resource', 'in', [str(record1), str(record2)]),
+                        ])
+
+            with self.assertRaises(AccessError):
+                Note.search([
+                        ('message', '=', 'Foo'),
+                        ('resource', '=', str(record2)),
+                        ])
+
+            with self.assertRaises(AccessError):
+                Note.search([('message', '=', 'Foo')])

Reply via email to