details:   https://code.tryton.org/tryton/commit/8bb847000475
branch:    7.8
user:      Cédric Krier <[email protected]>
date:      Thu Jun 25 13:31:30 2026 +0200
description:
        Add ModelAccessProxy

        #14907
        (grafted from dafef28a1d65032628809acbfa130a2f2e74474f)
diffstat:

 modules/company/res.py                |   4 ++
 trytond/CHANGELOG                     |   1 +
 trytond/doc/ref/models.rst            |   9 ++++
 trytond/trytond/ir/model.py           |  26 ++-----------
 trytond/trytond/ir/rule.py            |  21 +++++-----
 trytond/trytond/model/__init__.py     |   3 +-
 trytond/trytond/model/modelstorage.py |  52 ++++++++++++++++++++++++---
 trytond/trytond/model/modelview.py    |  15 +++----
 trytond/trytond/res/user.py           |   4 ++
 trytond/trytond/tests/test_access.py  |  65 +++++++++++++++++++++++++++++++++++
 trytond/trytond/tests/test_rule.py    |  44 +++++++++++++++++++++++
 trytond/trytond/transaction.py        |   4 +-
 12 files changed, 201 insertions(+), 47 deletions(-)

diffs (485 lines):

diff -r 7a05844a9aff -r 8bb847000475 modules/company/res.py
--- a/modules/company/res.py    Tue Jun 09 13:01:23 2026 +0200
+++ b/modules/company/res.py    Thu Jun 25 13:31:30 2026 +0200
@@ -163,6 +163,10 @@
         Return an ordered tuple of company ids for the user
         '''
         transaction = Transaction()
+
+        if '_companies' in transaction.context:
+            return transaction.context['_companies']
+
         user_id = transaction.user
         companies = cls._get_companies_cache.get(user_id)
         if companies is not None:
diff -r 7a05844a9aff -r 8bb847000475 trytond/CHANGELOG
--- a/trytond/CHANGELOG Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/CHANGELOG Thu Jun 25 13:31:30 2026 +0200
@@ -1,3 +1,4 @@
+* Add ModelAccessProxy (issue14907)
 * Restrict Genshi evaluation (issue14869, issue5160)
 
 Version 7.8.11 - 2026-06-18
diff -r 7a05844a9aff -r 8bb847000475 trytond/doc/ref/models.rst
--- a/trytond/doc/ref/models.rst        Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/doc/ref/models.rst        Thu Jun 25 13:31:30 2026 +0200
@@ -870,6 +870,15 @@
      the string
 
 
+ModelAccessProxy
+================
+
+.. class:: ModelAccessProxy(record[, context])
+
+   A class proxying instance of :class:`ModelStorage` with check access using
+   ``context``.
+
+
 convert_from
 ------------
 
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/ir/model.py
--- a/trytond/trytond/ir/model.py       Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/ir/model.py       Thu Jun 25 13:31:30 2026 +0200
@@ -610,10 +610,6 @@
     @classmethod
     def get_access(cls, models):
         'Return access for models'
-        # root user above constraint
-        if Transaction().user == 0:
-            return defaultdict(lambda: defaultdict(lambda: True))
-
         pool = Pool()
         User = pool.get('res.user')
         cursor = Transaction().connection.cursor()
@@ -703,9 +699,8 @@
         Model = pool.get(model_name)
         assert mode in ['read', 'write', 'create', 'delete'], \
             'Invalid access mode for security'
-        transaction = Transaction()
-        if (transaction.user == 0
-                or (raise_exception and not transaction.check_access)):
+
+        if not Transaction().check_access:
             return True
 
         User = pool.get('res.user')
@@ -850,11 +845,6 @@
     @classmethod
     def get_access(cls, models):
         'Return fields access for models'
-        # root user above constraint
-        if Transaction().user == 0:
-            return defaultdict(lambda: defaultdict(
-                    lambda: defaultdict(lambda: True)))
-
         pool = Pool()
         User = pool.get('res.user')
         field_access = cls.__table__()
@@ -900,8 +890,7 @@
         return accesses
 
     @classmethod
-    def check(cls, model_name, fields, mode='read', raise_exception=True,
-            access=False):
+    def check(cls, model_name, fields, mode='read', raise_exception=True):
         '''
         Check access for fields on model_name.
         '''
@@ -909,11 +898,8 @@
         Model = pool.get(model_name)
         assert mode in ('read', 'write', 'create', 'delete'), \
             'Invalid access mode'
-        transaction = Transaction()
-        if (transaction.user == 0
-                or (raise_exception and not transaction.check_access)):
-            if access:
-                return dict((x, True) for x in fields)
+
+        if not Transaction().check_access:
             return True
 
         User = pool.get('res.user')
@@ -921,8 +907,6 @@
 
         accesses = dict((f, a[mode])
             for f, a in cls.get_access([model_name])[model_name].items())
-        if access:
-            return accesses
         for field in fields:
             if not accesses.get(field, True):
                 if raise_exception:
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/ir/rule.py
--- a/trytond/trytond/ir/rule.py        Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/ir/rule.py        Thu Jun 25 13:31:30 2026 +0200
@@ -11,7 +11,8 @@
 from trytond.pool import Pool
 from trytond.pyson import Eval, If, PYSONDecoder
 from trytond.tools import grouped_slice
-from trytond.transaction import Transaction, inactive_records
+from trytond.transaction import (
+    Transaction, inactive_records, without_check_access)
 
 
 class DomainError(ValidationError):
@@ -240,9 +241,6 @@
         model_names = list(model_names)
 
         cursor = transaction.connection.cursor()
-        # root user above constraint
-        if transaction.user == 0:
-            return {}, {}
         cursor.execute(*rule_table.join(rule_group,
                 condition=rule_group.id == rule_table.rule_group
                 ).join(rule_group_group, 'LEFT',
@@ -267,8 +265,8 @@
 
         clause = defaultdict(lambda: ['OR'])
         clause_global = defaultdict(lambda: ['OR'])
-        # Use root user without context to prevent recursion
-        with transaction.set_user(0), transaction.set_context(user=0):
+        # Without check access to prevent recursion
+        with without_check_access():
             rules = cls.browse(ids)
         for rule in rules:
             decoder = PYSONDecoder(
@@ -296,9 +294,8 @@
     @classmethod
     def domain_get(cls, model_name, mode='read'):
         pool = Pool()
-        transaction = Transaction()
-        # root user above constraint
-        if transaction.user == 0 or not transaction.check_access:
+
+        if not Transaction().check_access:
             return []
 
         assert mode in cls.modes
@@ -349,7 +346,9 @@
         def test_domain(ids, domain):
             wrong_ids = []
             # Use root to prevent infinite recursion
-            with transaction.set_user(0, set_context=True), inactive_records():
+            with transaction.set_user(0, set_context=True), \
+                    inactive_records(), \
+                    without_check_access():
                 for sub_ids in grouped_slice(ids):
                     sub_ids = list(sub_ids)
                     records = Model.search([
@@ -361,6 +360,8 @@
             return wrong_ids
 
         domain = cls.domain_get(model_name, mode=mode)
+        if not domain:
+            return
         forbidden = test_domain(ids, domain)
         if forbidden:
             ids = ', '.join(map(str, forbidden[:5]))
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/model/__init__.py
--- a/trytond/trytond/model/__init__.py Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/model/__init__.py Thu Jun 25 13:31:30 2026 +0200
@@ -10,7 +10,7 @@
 from .model import Model
 from .modelsingleton import ModelSingleton
 from .modelsql import Check, Exclude, Index, ModelSQL, Unique, convert_from
-from .modelstorage import EvalEnvironment, ModelStorage
+from .modelstorage import EvalEnvironment, ModelAccessProxy, ModelStorage
 from .modelview import ModelView
 from .multivalue import MultiValueMixin, ValueMixin
 from .order import sequence_ordered, sequence_reorder, sort
@@ -30,6 +30,7 @@
     Index,
     MatchMixin,
     Model,
+    ModelAccessProxy,
     ModelSQL,
     ModelSingleton,
     ModelStorage,
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/model/modelstorage.py
--- a/trytond/trytond/model/modelstorage.py     Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/model/modelstorage.py     Thu Jun 25 13:31:30 2026 +0200
@@ -29,7 +29,8 @@
 from trytond.tools.domain_inversion import domain_inversion, eval_domain
 from trytond.tools.domain_inversion import parse as domain_parse
 from trytond.transaction import (
-    Transaction, inactive_records, record_cache_size, without_check_access)
+    Transaction, check_access, inactive_records, record_cache_size,
+    without_check_access)
 
 from . import fields
 from .descriptors import dualmethod
@@ -1891,11 +1892,11 @@
 
         if load_eager or multiple_getter:
             FieldAccess = pool.get('ir.model.field.access')
-            fread_accesses = {}
-            fread_accesses.update(FieldAccess.check(self.__name__,
-                list(self._fields.keys()), 'read', access=True))
-            to_remove = set(x for x, y in fread_accesses.items()
-                    if not y and x != name)
+            fields_access = FieldAccess.get_access(
+                [self.__name__])[self.__name__]
+            to_remove = {
+                f for f, a in fields_access.items() if not a['read']}
+            to_remove.discard(name)
 
             def not_cached(item):
                 fname, field = item
@@ -2293,3 +2294,42 @@
 
 
 _pyson_encoder = PYSONEncoder()
+
+
+def ModelAccessProxy(record, context=None):
+    pool = Pool()
+    ModelAccess = pool.get('ir.model.access')
+    FieldAccess = pool.get('ir.model.field.access')
+    Rule = pool.get('ir.rule')
+
+    model = record.__class__
+    with Transaction().set_context(context), check_access():
+        ModelAccess.check(model.__name__)
+        Rule.check(model.__name__, [record.id])
+
+    class _ModelAccessProxy:
+        __class__ = model
+
+        def __init__(self, id):
+            self.id = id
+
+        def __getattr__(self, name):
+            if name in model._fields:
+                with Transaction().set_context(context), check_access():
+                    FieldAccess.check(model.__name__, [name])
+            value = getattr(record, name)
+            if isinstance(value, Model):
+                value = ModelAccessProxy(value, context)
+            elif isinstance(value, (list, tuple)):
+                value = [
+                    ModelAccessProxy(r, context)
+                    if isinstance(r, Model) else r for r in value]
+            return value
+
+        def __int__(self):
+            return int(self.id)
+
+        def __str__(self):
+            return f'{model.__name__},{self.id}'
+
+    return _ModelAccessProxy(record.id)
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/model/modelview.py
--- a/trytond/trytond/model/modelview.py        Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/model/modelview.py        Thu Jun 25 13:31:30 2026 +0200
@@ -437,10 +437,9 @@
         tree_root = tree.getroottree().getroot()
 
         # Find field without read access
-        fread_accesses = FieldAccess.check(cls.__name__,
-                list(cls._fields.keys()), 'read', access=True)
-        fields_to_remove = set(
-            x for x, y in fread_accesses.items() if not y)
+        fields_access = FieldAccess.get_access([cls.__name__])[cls.__name__]
+        fields_to_remove = {
+            f for f, a in fields_access.items() if not a['read']}
 
         # Find relation field without read access
         for name, field in cls._fields.items():
@@ -666,8 +665,8 @@
             button_groups = Button.get_groups(cls.__name__, button_name)
             if ((button_groups and not groups & button_groups)
                     or (not button_groups
-                        and not ModelAccess.check(
-                            cls.__name__, 'write', raise_exception=False))):
+                        and not ModelAccess.get_access(
+                            [cls.__name__])[cls.__name__]['write'])):
                 states = states.copy()
                 states['readonly'] = True
             element.set('states', encoder.encode(states))
@@ -702,8 +701,8 @@
                 action = None
             if (not action
                     or not action.res_model
-                    or not ModelAccess.check(
-                        action.res_model, 'read', raise_exception=False)):
+                    or not ModelAccess.get_access(
+                        [action.res_model])[action.res_model]['read']):
                 element.tag = 'label'
                 colspan = element.attrib.get('colspan')
                 link_name = element.attrib['name']
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/res/user.py
--- a/trytond/trytond/res/user.py       Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/res/user.py       Thu Jun 25 13:31:30 2026 +0200
@@ -619,6 +619,10 @@
         Group = pool.get('res.group')
 
         transaction = Transaction()
+
+        if '_groups' in transaction.context:
+            return transaction.context['_groups']
+
         user = transaction.user
         groups = cls._get_groups_cache.get(user)
         if groups is not None:
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/tests/test_access.py
--- a/trytond/trytond/tests/test_access.py      Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/tests/test_access.py      Thu Jun 25 13:31:30 2026 +0200
@@ -2,6 +2,7 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
 
+from trytond.model import ModelAccessProxy
 from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.tests.test_tryton import (
@@ -394,6 +395,35 @@
         with self.assertRaises(AccessError):
             TestAccess.search([], order=[('relate.value', 'ASC')])
 
+    @with_transaction()
+    def test_model_access_proxy(self):
+        "Test model access proxy"
+        pool = Pool()
+        ModelAccess = pool.get('ir.model.access')
+        TestAccess = pool.get(self.model_name)
+        record, = TestAccess.create([{}])
+        ModelAccess.create([{
+                    'model': self.model_name,
+                    'perm_read': True,
+                    }])
+
+        ModelAccessProxy(record, {})
+
+    @with_transaction()
+    def test_model_access_proxy_no_access(self):
+        "Test model access proxy without access"
+        pool = Pool()
+        ModelAccess = pool.get('ir.model.access')
+        TestAccess = pool.get(self.model_name)
+        record, = TestAccess.create([{}])
+        ModelAccess.create([{
+                    'model': self.model_name,
+                    'perm_read': False,
+                    }])
+
+        with self.assertRaises(AccessError):
+            ModelAccessProxy(record, {})
+
 
 class ModelAccessWriteTestCase(_ModelAccessTestCase):
     _perm = 'perm_write'
@@ -1101,6 +1131,41 @@
         with self.assertRaises(AccessError):
             TestAccess.search([('relate', 'child_of', 42, 'parent')])
 
+    @with_transaction()
+    def test_model_access_proxy(self):
+        "Test model access proxy"
+        pool = Pool()
+        FieldAccess = pool.get('ir.model.field.access')
+        TestAccess = pool.get('test.access')
+        record, = TestAccess.create([{'field1': "foo"}])
+        FieldAccess.create([{
+                    'model': 'test.access',
+                    'field': 'field1',
+                    'perm_read': True,
+                    }])
+
+        proxy = ModelAccessProxy(record, {})
+
+        self.assertEqual(proxy.field1, 'foo')
+
+    @with_transaction()
+    def test_model_access_proxy_no_access(self):
+        "Test model access proxy without access"
+        pool = Pool()
+        FieldAccess = pool.get('ir.model.field.access')
+        TestAccess = pool.get('test.access')
+        record, = TestAccess.create([{'field1': "foo"}])
+        FieldAccess.create([{
+                    'model': 'test.access',
+                    'field': 'field1',
+                    'perm_read': False,
+                    }])
+
+        proxy = ModelAccessProxy(record, {})
+
+        with self.assertRaises(AccessError):
+            ModelAccessProxy(proxy.field1)
+
 
 class ModelFieldAccessWriteTestCase(_ModelFieldAccessTestCase):
     _perm = 'perm_write'
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/tests/test_rule.py
--- a/trytond/trytond/tests/test_rule.py        Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/tests/test_rule.py        Thu Jun 25 13:31:30 2026 +0200
@@ -2,6 +2,7 @@
 # this repository contains the full copyright notices and license terms.
 import json
 
+from trytond.model import ModelAccessProxy
 from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.tests.test_tryton import (
@@ -663,3 +664,46 @@
 
         with self.assertRaisesRegex(AccessError, "Field different from foo"):
             TestRuleModel.read([test.id], ['name'])
+
+    @with_transaction()
+    def test_model_access_proxy(self):
+        "Test model access proxy"
+        pool = Pool()
+        TestRule = pool.get('test.rule')
+        RuleGroup = pool.get('ir.rule.group')
+        rule_group, = RuleGroup.create([{
+                    'name': "Field different from foo",
+                    'model': TestRule.__name__,
+                    'global_p': True,
+                    'perm_read': False,
+                    'rules': [('create', [{
+                                    'domain': json.dumps(
+                                        [('field', '!=', 'foo')]),
+                                    }])],
+                    }])
+        record, = TestRule.create([{'field': 'foo'}])
+
+        proxy = ModelAccessProxy(record, {})
+
+        self.assertEqual(proxy.field, 'foo')
+
+    @with_transaction()
+    def test_model_access_proxy_no_access(self):
+        "Test model access proxy without access"
+        pool = Pool()
+        TestRule = pool.get('test.rule')
+        RuleGroup = pool.get('ir.rule.group')
+        rule_group, = RuleGroup.create([{
+                    'name': "Field different from foo",
+                    'model': TestRule.__name__,
+                    'global_p': True,
+                    'perm_read': True,
+                    'rules': [('create', [{
+                                    'domain': json.dumps(
+                                        [('field', '!=', 'foo')]),
+                                    }])],
+                    }])
+        record, = TestRule.create([{'field': 'foo'}])
+
+        with self.assertRaises(AccessError):
+            ModelAccessProxy(record, {})
diff -r 7a05844a9aff -r 8bb847000475 trytond/trytond/transaction.py
--- a/trytond/trytond/transaction.py    Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/transaction.py    Thu Jun 25 13:31:30 2026 +0200
@@ -437,7 +437,9 @@
 
     @property
     def check_access(self):
-        return self.context.get('_check_access', False)
+        return (
+            self.context.get('_check_access', False)
+            and self.user != 0)
 
     @property
     def active_records(self):

Reply via email to