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