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
This commit is contained in:
Nicolas Évrard committed 2026-09-11 17:53:07 +02:00
1 parent 305301a53e
commit 6fafcaca82
3 files changed
+141 -95

No files matched your search

+3
View File
@@ -414,6 +414,9 @@ this repository contains the full copyright notices and license terms. -->
<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>
+55 -52
View File
@@ -6,11 +6,12 @@ from collections import defaultdict
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']
@@ -79,59 +80,61 @@ class ResourceAccessMixin(ModelStorage):
return [
(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):
+83 -43
View File
@@ -78,31 +78,6 @@ class ResourceTestCase(DBTestCase):
note = Note(note.id)
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"
@@ -251,27 +226,92 @@ class ResourceTestCase(DBTestCase):
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')
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()
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",
}])
with Transaction().set_context(_check_access=True):
notes = Note.search([], limit=10, offset=0)
self.assertEqual(len(notes), 10)
with Transaction().set_user(user.id):
notes = Note.search([
('message', '=', 'Foo'),
('resource', '=', str(record1)),
])
self.assertEqual([note], notes)
notes = Note.search([], limit=200, offset=0)
self.assertEqual(len(notes), 66)
notes = Note.search([
('message', '=', 'Foo'),
('resource', 'in', [str(record1), str(record2)]),
])
self.assertEqual([note], notes)
notes = Note.search([])
self.assertEqual(len(notes), 66)
notes = Note.search([
('message', '=', 'Foo'),
('resource', '=', str(record2)),
])
self.assertEqual([], notes)
with self.assertRaises(AccessError):
Note.search([('message', '=', 'Foo')])
@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')
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",
}])
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')])