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):