details:   https://code.tryton.org/tryton/commit/4f0f139b4cb6
branch:    7.0
user:      Cédric Krier <[email protected]>
date:      Wed Jul 01 00:46:07 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           |  37 ++++++----------
 trytond/trytond/ir/rule.py            |  58 +++++++++++++++++++++----
 trytond/trytond/model/__init__.py     |   4 +-
 trytond/trytond/model/modelstorage.py |  52 ++++++++++++++++++++--
 trytond/trytond/model/modelview.py    |  15 +++---
 trytond/trytond/res/user.py           |   4 +
 trytond/trytond/tests/test_access.py  |  77 +++++++++++++++++++++++++++++++++++
 trytond/trytond/tests/test_rule.py    |  50 ++++++++++++++++++++++
 trytond/trytond/transaction.py        |   4 +-
 12 files changed, 265 insertions(+), 50 deletions(-)

diffs (546 lines):

diff -r fdce3d76bc60 -r 4f0f139b4cb6 modules/company/res.py
--- a/modules/company/res.py    Tue Jun 09 13:01:23 2026 +0200
+++ b/modules/company/res.py    Wed Jul 01 00:46:07 2026 +0200
@@ -193,6 +193,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 fdce3d76bc60 -r 4f0f139b4cb6 trytond/CHANGELOG
--- a/trytond/CHANGELOG Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/CHANGELOG Wed Jul 01 00:46:07 2026 +0200
@@ -1,3 +1,4 @@
+* Add ModelAccessProxy (issue14907)
 * Restrict Genshi evaluation (issue14869, issue5160)
 
 Version 7.0.52 - 2026-06-18
diff -r fdce3d76bc60 -r 4f0f139b4cb6 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        Wed Jul 01 00:46:07 2026 +0200
@@ -775,6 +775,15 @@
      the string
 
 
+ModelAccessProxy
+================
+
+.. class:: ModelAccessProxy(record[, context])
+
+   A class proxying instance of :class:`ModelStorage` with check access using
+   ``context``.
+
+
 convert_from
 ------------
 
diff -r fdce3d76bc60 -r 4f0f139b4cb6 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       Wed Jul 01 00:46:07 2026 +0200
@@ -530,13 +530,12 @@
     @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()
         Model = pool.get('ir.model')
-        User = pool.get('res.user')
+        try:
+            User = pool.get('res.user')
+        except KeyError:
+            return defaultdict(lambda: defaultdict(lambda: True))
         cursor = Transaction().connection.cursor()
         model_access = cls.__table__()
         ir_model = Model.__table__()
@@ -627,9 +626,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
 
         access = cls.get_access([model_name])[model_name][mode]
@@ -749,15 +747,14 @@
     @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()
         Model = pool.get('ir.model')
         ModelField = pool.get('ir.model.field')
-        User = pool.get('res.user')
+        try:
+            User = pool.get('res.user')
+        except KeyError:
+            return defaultdict(lambda: defaultdict(
+                    lambda: defaultdict(lambda: True)))
         field_access = cls.__table__()
         ir_model = Model.__table__()
         model_field = ModelField.__table__()
@@ -807,8 +804,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.
         '''
@@ -816,17 +812,12 @@
         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
 
         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 fdce3d76bc60 -r 4f0f139b4cb6 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        Wed Jul 01 00:46:07 2026 +0200
@@ -7,10 +7,11 @@
 from trytond.cache import Cache
 from trytond.i18n import gettext
 from trytond.model import Check, Index, ModelSQL, ModelView, fields
-from trytond.model.exceptions import ValidationError
+from trytond.model.exceptions import AccessError, ValidationError
 from trytond.pool import Pool
 from trytond.pyson import PYSONDecoder
-from trytond.transaction import Transaction, inactive_records
+from trytond.transaction import (
+    Transaction, inactive_records, without_check_access)
 
 
 class DomainError(ValidationError):
@@ -205,9 +206,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(model,
@@ -236,8 +234,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(
@@ -265,9 +263,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
@@ -312,6 +309,47 @@
             return Model.search(domain, order=[], query=True)
 
     @classmethod
+    def check(cls, model_name, ids, mode='read'):
+        pool = Pool()
+        Model = pool.get(model_name)
+        transaction = Transaction()
+
+        def test_domain(ids, domain):
+            # Use root to prevent infinite recursion
+            with transaction.set_user(0, set_context=True), \
+                    inactive_records(), \
+                    without_check_access():
+                records = Model.search([
+                        ('id', 'in', ids),
+                        domain,
+                        ], order=[])
+            return list(set(ids).difference(map(int, records)))
+
+        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]))
+            if len(forbidden) > 5:
+                ids += '...'
+            rules = []
+            clause, clause_global = cls.get(model_name, mode=mode)
+            if clause:
+                dom = list(clause.values())
+                dom.insert(0, 'OR')
+                if test_domain(forbidden, dom):
+                    rules.extend(clause.keys())
+                for rule, dom in clause_global.items():
+                    if test_domain(forbidden, dom):
+                        rules.append(rule)
+            raise AccessError(gettext(
+                    f'ir.msg_{mode}_rule_error',
+                    ids=ids,
+                    rules='\n'.join(r.name for r in rules),
+                    **Model.__names__()))
+
+    @classmethod
     def delete(cls, rules):
         super(Rule, cls).delete(rules)
         # Restart the cache on the domain_get method of ir.rule
diff -r fdce3d76bc60 -r 4f0f139b4cb6 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 Wed Jul 01 00:46:07 2026 +0200
@@ -9,7 +9,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, sort
@@ -23,4 +23,4 @@
     'Workflow', 'DictSchemaMixin', 'MatchMixin', 'UnionMixin', 'dualmethod',
     'MultiValueMixin', 'ValueMixin', 'SymbolMixin', 'DigitsMixin',
     'EvalEnvironment', 'sequence_ordered', 'sort', 'DeactivableMixin', 'tree',
-    'sum_tree', 'avatar_mixin']
+    'sum_tree', 'avatar_mixin', 'ModelAccessProxy']
diff -r fdce3d76bc60 -r 4f0f139b4cb6 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     Wed Jul 01 00:46:07 2026 +0200
@@ -28,7 +28,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
@@ -1705,11 +1706,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
@@ -2090,3 +2091,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 fdce3d76bc60 -r 4f0f139b4cb6 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        Wed Jul 01 00:46:07 2026 +0200
@@ -408,10 +408,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():
@@ -631,8 +630,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))
@@ -667,8 +666,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 fdce3d76bc60 -r 4f0f139b4cb6 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       Wed Jul 01 00:46:07 2026 +0200
@@ -636,6 +636,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 fdce3d76bc60 -r 4f0f139b4cb6 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      Wed Jul 01 00:46:07 2026 +0200
@@ -3,6 +3,7 @@
 # this repository contains the full copyright notices and license terms.
 import unittest
 
+from trytond.model import ModelAccessProxy
 from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.tests.test_tryton import activate_module, with_transaction
@@ -404,6 +405,39 @@
         with self.assertRaises(AccessError):
             TestAccess.search([], order=[('relate.value', 'ASC')])
 
+    @with_transaction()
+    def test_model_access_proxy(self):
+        "Test model access proxy"
+        pool = Pool()
+        Model = pool.get('ir.model')
+        ModelAccess = pool.get('ir.model.access')
+        TestAccess = pool.get(self.model_name)
+        record, = TestAccess.create([{}])
+        model, = Model.search([('model', '=', self.model_name)])
+        ModelAccess.create([{
+                    'model': model.id,
+                    'perm_read': True,
+                    }])
+
+        ModelAccessProxy(record, {})
+
+    @with_transaction()
+    def test_model_access_proxy_no_access(self):
+        "Test model access proxy without access"
+        pool = Pool()
+        Model = pool.get('ir.model')
+        ModelAccess = pool.get('ir.model.access')
+        TestAccess = pool.get(self.model_name)
+        record, = TestAccess.create([{}])
+        model, = Model.search([('model', '=', self.model_name)])
+        ModelAccess.create([{
+                    'model': model.id,
+                    'perm_read': False,
+                    }])
+
+        with self.assertRaises(AccessError):
+            ModelAccessProxy(record, {})
+
 
 class ModelAccessWriteTestCase(_ModelAccessTestCase):
     _perm = 'perm_write'
@@ -1105,6 +1139,49 @@
         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()
+        Field = pool.get('ir.model.field')
+        FieldAccess = pool.get('ir.model.field.access')
+        TestAccess = pool.get('test.access')
+        record, = TestAccess.create([{'field1': "foo"}])
+        field, = Field.search([
+                ('model.model', '=', 'test.access'),
+                ('name', '=', 'field1'),
+                ])
+        FieldAccess.create([{
+                    'field': field.id,
+                    '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()
+        Field = pool.get('ir.model.field')
+        FieldAccess = pool.get('ir.model.field.access')
+        TestAccess = pool.get('test.access')
+        record, = TestAccess.create([{'field1': "foo"}])
+        field, = Field.search([
+                ('model.model', '=', 'test.access'),
+                ('name', '=', 'field1'),
+                ])
+        FieldAccess.create([{
+                    'field': field.id,
+                    'perm_read': False,
+                    }])
+
+        proxy = ModelAccessProxy(record, {})
+
+        with self.assertRaises(AccessError):
+            ModelAccessProxy(proxy.field1)
+
 
 class ModelFieldAccessWriteTestCase(_ModelFieldAccessTestCase):
     _perm = 'perm_write'
diff -r fdce3d76bc60 -r 4f0f139b4cb6 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        Wed Jul 01 00:46:07 2026 +0200
@@ -3,6 +3,7 @@
 import json
 import unittest
 
+from trytond.model import ModelAccessProxy
 from trytond.model.exceptions import AccessError
 from trytond.pool import Pool
 from trytond.tests.test_tryton import activate_module, with_transaction
@@ -708,3 +709,52 @@
 
         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')
+        Model = pool.get('ir.model')
+
+        model, = Model.search([('model', '=', 'test.rule')])
+        rule_group, = RuleGroup.create([{
+                    'name': "Field different from foo",
+                    'model': model.id,
+                    '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')
+        Model = pool.get('ir.model')
+
+        model, = Model.search([('model', '=', 'test.rule')])
+        rule_group, = RuleGroup.create([{
+                    'name': "Field different from foo",
+                    'model': model.id,
+                    '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 fdce3d76bc60 -r 4f0f139b4cb6 trytond/trytond/transaction.py
--- a/trytond/trytond/transaction.py    Tue Jun 09 13:01:23 2026 +0200
+++ b/trytond/trytond/transaction.py    Wed Jul 01 00:46:07 2026 +0200
@@ -406,7 +406,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