details:   https://code.tryton.org/tryton/commit/6c611c520744
branch:    default
user:      Cédric Krier <[email protected]>
date:      Sat Sep 19 08:09:17 2026 +0200
description:
        Enforce readonly on field when checking access

        Closes #4207
diffstat:

 trytond/CHANGELOG                      |    1 +
 trytond/doc/ref/models.rst             |    5 +-
 trytond/trytond/ir/message.xml         |    6 +
 trytond/trytond/model/modelsql.py      |    3 +-
 trytond/trytond/model/modelstorage.py  |  100 ++++++++++++++++++++++++--------
 trytond/trytond/tests/access.py        |   12 +++
 trytond/trytond/tests/test_access.py   |   88 +++++++++++++++++++++++++++-
 trytond/trytond/tests/test_report.py   |   18 +++-
 trytond/trytond/tests/test_resource.py |    4 +-
 9 files changed, 197 insertions(+), 40 deletions(-)

diffs (472 lines):

diff -r bdec21275499 -r 6c611c520744 trytond/CHANGELOG
--- a/trytond/CHANGELOG Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/CHANGELOG Sat Sep 19 08:09:17 2026 +0200
@@ -1,3 +1,4 @@
+* Enforce readonly on field when checking access
 * Add the icon parameter to register_authentication_service
 * Add routes for sao custom in base router
 * Replace the administration group by a flag on the user
diff -r bdec21275499 -r 6c611c520744 trytond/doc/ref/models.rst
--- a/trytond/doc/ref/models.rst        Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/doc/ref/models.rst        Sat Sep 19 08:09:17 2026 +0200
@@ -428,8 +428,9 @@
 
 .. classmethod:: ModelStorage.check_modification(mode, records[, values[, 
external]])
 
-   Method called after ``records`` are created and before ``records`` are
-   modified with ``values`` or deleted.
+   Method called after ``records`` are created with ``values`` as a list of
+   created values and before ``records`` are modified with the modified
+   ``values`` or deleted without ``values``.
    ``external`` specifies whether remote access check must be enforced.
 
    .. note::
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/ir/message.xml
--- a/trytond/trytond/ir/message.xml    Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/ir/message.xml    Sat Sep 19 08:09:17 2026 +0200
@@ -244,6 +244,12 @@
             <field name="text">You are not allowed to delete records "%(ids)s" 
of "%(model)s" because of at lease one of those rules:
 %(rules)s</field>
         </record>
+        <record model="ir.message" id="msg_modification_readonly_field_error">
+            <field name="text">You are not allowed to modify the field 
"%(field)s" of "%(model)s".</field>
+        </record>
+        <record model="ir.message" 
id="msg_modification_readonly_field_record_error">
+            <field name="text">You are not allowed to modify the field 
"%(field)s" in record "%(record)s" of "%(model)s".</field>
+        </record>
         <record model="ir.message" id="msg_context_datetime">
             <field name="text">At date/time: %(datetime)s</field>
         </record>
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/model/modelsql.py
--- a/trytond/trytond/model/modelsql.py Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/model/modelsql.py Sat Sep 19 08:09:17 2026 +0200
@@ -1008,6 +1008,7 @@
         pool = Pool()
         Translation = pool.get('ir.translation')
 
+        vlist_orig = vlist
         vlist = cls._before_create(vlist)
 
         table = cls.__table__()
@@ -1179,7 +1180,7 @@
         cls._insert_history(new_ids)
 
         cls.__check_domain_rule(new_ids, 'create')
-        return cls.browse(cls._after_create(new_ids))
+        return cls.browse(cls._after_create(new_ids, vlist_orig))
 
     @classmethod
     def read(cls, ids, fields_names):
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/model/modelstorage.py
--- a/trytond/trytond/model/modelstorage.py     Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/model/modelstorage.py     Sat Sep 19 08:09:17 2026 +0200
@@ -324,7 +324,7 @@
         raise NotImplementedError
 
     @classmethod
-    def _after_create(cls, ids):
+    def _after_create(cls, ids, vlist):
         Trigger = Pool().get('ir.trigger')
         transaction = Transaction()
         check_access = transaction.user and transaction.check_access
@@ -336,7 +336,7 @@
                 records = cls.browse(sub_ids)
                 cls._validate(records)
                 cls.check_modification(
-                    'create', records, external=check_access)
+                    'create', records, values=vlist, external=check_access)
                 cls._compute_fields(records)
                 cls.on_modification('create', records)
                 if triggers:
@@ -562,6 +562,52 @@
     def check_modification(cls, mode, records, values=None, external=False):
         assert mode in {'create', 'write', 'delete'}
 
+        def test_readonly(records, field_name, override):
+            field = cls._fields[field_name]
+            if isinstance(field, (fields.One2Many, fields.Many2Many)):
+                # Must be enforced on the target
+                return
+            if field.readonly:
+                raise AccessError(
+                    gettext('ir.msg_modification_readonly_field_error',
+                        **cls.__names__(field=field_name)))
+            if 'readonly' not in field.states:
+                return
+            if is_pyson(field.states['readonly']):
+                pyson_readonly = PYSONEncoder().encode(
+                    field.states['readonly'])
+                for record in sub_records:
+                    readonly = _record_eval_pyson(
+                        record, pyson_readonly, encoded=True,
+                        override=override)
+                    if readonly:
+                        raise AccessError(
+                            gettext(
+                                'ir.'
+                                'msg_modification_readonly_field_record_error',
+                                **cls.__names__(
+                                    field=field_name,
+                                    record=record)))
+            elif field.states['readonly']:
+                for record in sub_records:
+                    raise AccessError(
+                        gettext(
+                            'ir.msg_modification_readonly_field_record_error',
+                            **cls.__names__(
+                                field=field_name,
+                                record=record)))
+
+        if external and values and mode in {'create', 'write'}:
+            if mode == 'create':
+                iterator = (([r], v) for r, v in zip(records, values))
+                override = {'id': -1}
+            else:
+                iterator = ((records, values),)
+                override = {}
+            for sub_records, sub_values in iterator:
+                for field_name in sub_values:
+                    test_readonly(sub_records, field_name, override)
+
     @classmethod
     def on_modification(cls, mode, records, field_names=None):
         assert mode in {'create', 'write', 'delete'}
@@ -1449,25 +1495,6 @@
     def _validate(cls, records, field_names=None):
         pool = Pool()
 
-        def is_pyson(test):
-            if isinstance(test, PYSON):
-                return True
-            if isinstance(test, (list, tuple)):
-                for i in test:
-                    if isinstance(i, PYSON):
-                        return True
-                    if isinstance(i, (list, tuple)):
-                        if is_pyson(i):
-                            return True
-            if isinstance(test, dict):
-                for key, value in list(test.items()):
-                    if isinstance(value, PYSON):
-                        return True
-                    if isinstance(value, (list, tuple, dict)):
-                        if is_pyson(value):
-                            return True
-            return False
-
         def validate_domain(field):
             if not field.domain:
                 return
@@ -2462,15 +2489,38 @@
         self._ids.extend(map(int, list.__iter__(self)))
 
 
+def is_pyson(test):
+    if isinstance(test, PYSON):
+        return True
+    if isinstance(test, (list, tuple)):
+        for i in test:
+            if isinstance(i, PYSON):
+                return True
+            if isinstance(i, (list, tuple)):
+                if is_pyson(i):
+                    return True
+    if isinstance(test, dict):
+        for key, value in list(test.items()):
+            if isinstance(value, PYSON):
+                return True
+            if isinstance(value, (list, tuple, dict)):
+                if is_pyson(value):
+                    return True
+    return False
+
+
 class EvalEnvironment(dict):
-    __slots__ = ('_record', '_model')
+    __slots__ = ('_record', '_model', '_override')
 
-    def __init__(self, record, Model):
+    def __init__(self, record, Model, override=None):
         super().__init__()
         self._record = record
         self._model = Model
+        self._override = dict(override) if override is not None else {}
 
     def __getitem__(self, item):
+        if item in self._override:
+            return self._override[item]
         if item.startswith('_parent_'):
             field = item[8:]
             model_name = self._model._fields[field].model_name
@@ -2506,13 +2556,13 @@
         return bool(self._record)
 
 
-def _record_eval_pyson(record, source, encoded=False):
+def _record_eval_pyson(record, source, encoded=False, override=None):
     transaction = Transaction()
     if not encoded:
         pyson = _pyson_encoder.encode(source)
     else:
         pyson = source
-    env = EvalEnvironment(record, record.__class__)
+    env = EvalEnvironment(record, record.__class__, override=override)
     env['context'] = transaction.context
     env['active_model'] = record.__class__.__name__
     env['active_id'] = record.id
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/tests/access.py
--- a/trytond/trytond/tests/access.py   Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/tests/access.py   Sat Sep 19 08:09:17 2026 +0200
@@ -2,6 +2,7 @@
 # this repository contains the full copyright notices and license terms.
 from trytond.model import ModelSQL, fields
 from trytond.pool import Pool
+from trytond.pyson import Eval
 
 
 class TestAccess(ModelSQL):
@@ -14,6 +15,17 @@
             ('test.access.relate', "Reference"),
             ])
     dict_ = fields.Dict(None, "Dict")
+    field_readonly = fields.Char("Field Readonly", readonly=True)
+    field_readonly_state = fields.Char(
+        "Field Readonly State",
+        states={
+            'readonly': Eval('field1') == 'readonly',
+            })
+    field_readonly_id = fields.Char(
+        "Field Readonly ID",
+        states={
+            'readonly': Eval('id', -1) >= 0,
+            })
 
 
 class TestAccessRelate(ModelSQL):
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/tests/test_access.py
--- a/trytond/trytond/tests/test_access.py      Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/tests/test_access.py      Sat Sep 19 08:09:17 2026 +0200
@@ -12,6 +12,80 @@
 _context = {'_check_access': True}
 
 
+class ReadonlyTestCase(DBTestCase):
+    module = 'tests'
+
+    @with_transaction(context=_context)
+    def test_create_readonly(self):
+        "Test create readonly field"
+        pool = Pool()
+        Model = pool.get('test.access')
+
+        with self.assertRaises(AccessError):
+            Model.create([{'field_readonly': "test"}])
+
+    @with_transaction(context=_context)
+    def test_write_readonly(self):
+        pool = Pool()
+        Model = pool.get('test.access')
+
+        record, = Model.create([{}])
+
+        with self.assertRaises(AccessError):
+            Model.write([record], {'field_readonly': "test"})
+
+    @with_transaction(context=_context)
+    def test_create_readonly_state(self):
+        "Test create field with readonly states"
+        pool = Pool()
+        Model = pool.get('test.access')
+
+        Model.create([{
+                    'field1': 'not readonly',
+                    'field_readonly_state': 'test',
+                    }])
+        Model.create([{
+                    'field1': 'readonly',
+                    }])
+        with self.assertRaises(AccessError):
+            Model.create([{
+                        'field1': 'readonly',
+                        'field_readonly_state': 'test',
+                        }])
+
+    @with_transaction(context=_context)
+    def test_write_readonly_state(self):
+        "Test write field with readonly states"
+        pool = Pool()
+        Model = pool.get('test.access')
+
+        record, = Model.create([{}])
+
+        Model.write([record], {
+                'field1': 'not readonly',
+                'field_readonly_state': 'foo',
+                })
+        Model.write([record], {
+                'field1': 'readonly',
+                })
+        with self.assertRaises(AccessError):
+            Model.write([record], {
+                    'field_readonly_state': 'bar',
+                    })
+
+    @with_transaction(context=_context)
+    def test_readonly_id(self):
+        "Test field with readonly based on id"
+        pool = Pool()
+        Model = pool.get('test.access')
+
+        record, = Model.create([{'field_readonly_id': 'foo'}])
+        with self.assertRaises(AccessError):
+            Model.write([record], {
+                    'field_readonly_id': 'bar',
+                    })
+
+
 class _ModelAccessTestCase(DBTestCase):
     module = 'tests'
     _perm = None
@@ -261,7 +335,9 @@
         ModelAccess = pool.get('ir.model.access')
         TestAccess = pool.get(self.model_name)
 
-        inactive_group, = Group.create([{'name': 'Test', 'active': False}])
+        inactive_group, = Group.create([{'name': 'Test'}])
+        inactive_group.active = False
+        inactive_group.save()
         record, = TestAccess.create([{}])
         ModelAccess.create([{
                     'model': self.model_name,
@@ -287,9 +363,10 @@
 
         inactive_group, = Group.create([{
                     'name': 'Test',
-                    'active': False,
                     'users': [('add', [Transaction().user])],
                     }])
+        inactive_group.active = False
+        inactive_group.save()
         record, = TestAccess.create([{}])
         ModelAccess.create([{
                     'model': self.model_name,
@@ -810,7 +887,9 @@
         FieldAccess = pool.get('ir.model.field.access')
         TestAccess = pool.get('test.access')
 
-        inactive_group, = Group.create([{'name': 'Test', 'active': False}])
+        inactive_group, = Group.create([{'name': 'Test'}])
+        inactive_group.active = False
+        inactive_group.save()
         record, = TestAccess.create([{}])
         FieldAccess.create([{
                     'model': 'test.access',
@@ -839,9 +918,10 @@
 
         inactive_group, = Group.create([{
                     'name': 'Test',
-                    'active': False,
                     'users': [('add', [Transaction().user])],
                     }])
+        inactive_group.active = False
+        inactive_group.save()
         record, = TestAccess.create([{}])
         FieldAccess.create([{
                     'model': 'test.access',
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/tests/test_report.py
--- a/trytond/trytond/tests/test_report.py      Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/tests/test_report.py      Sat Sep 19 08:09:17 2026 +0200
@@ -52,7 +52,7 @@
             Report.execute([], {}),
             ('txt', 'Administrator\n', False, 'Test Report'))
 
-    @with_transaction(context={'_check_access': True})
+    @with_transaction()
     def test_execute_without_access(self):
         "Execute report without model access"
         with file_open('report.xml', subdir='tests') as xml:
@@ -63,6 +63,7 @@
         User = pool.get('res.user')
         Group = pool.get('res.group')
         Report = pool.get('test.test_report', type='report')
+        transaction = Transaction()
 
         user = User(login='foo')
         user.save()
@@ -75,12 +76,13 @@
         action_report.save()
 
         with self.assertRaises(AccessError):
-            with Transaction().set_user(user.id):
+            with (transaction.set_user(user.id),
+                    transaction.set_context(_check_access=True)):
                 Report.execute(
                     [],
                     {'model': 'test.access', 'action_id': action_report.id})
 
-    @with_transaction(context={'_check_access': True})
+    @with_transaction()
     def test_execute_without_model_access(self):
         "Execute report without model access"
         with file_open('report.xml', subdir='tests') as xml:
@@ -90,6 +92,7 @@
         Report = pool.get('test.test_report', type='report')
         ModelAccess = pool.get('ir.model.access')
         User = pool.get('res.user')
+        transaction = Transaction()
 
         user = User(login='foo')
         user.save()
@@ -99,10 +102,11 @@
                     }])
 
         with self.assertRaises(AccessError):
-            with Transaction().set_user(user.id):
+            with (transaction.set_user(user.id),
+                    transaction.set_context(_check_access=True)):
                 Report.execute([], {'model': 'test.access'})
 
-    @with_transaction(context={'_check_access': True})
+    @with_transaction()
     def test_execute_without_read_access(self):
         "Execute report without read access"
         with file_open('report.xml', subdir='tests') as xml:
@@ -113,6 +117,7 @@
         Report = pool.get('test.test_report', type='report')
         Model = pool.get('test.access')
         RuleGroup = pool.get('ir.rule.group')
+        transaction = Transaction()
 
         record, = Model.create([{'field1': 'foo'}])
         rule_group, = RuleGroup.create([{
@@ -130,7 +135,8 @@
         user.save()
 
         with self.assertRaises(AccessError):
-            with Transaction().set_user(user.id):
+            with (transaction.set_user(user.id),
+                    transaction.set_context(_check_access=True)):
                 Report.execute([record.id], {'model': 'test.access'})
 
     @unittest.skipUnless(mrml, "required mrml")
diff -r bdec21275499 -r 6c611c520744 trytond/trytond/tests/test_resource.py
--- a/trytond/trytond/tests/test_resource.py    Fri Sep 18 23:52:09 2026 +0200
+++ b/trytond/trytond/tests/test_resource.py    Sat Sep 19 08:09:17 2026 +0200
@@ -104,7 +104,7 @@
                         'resource': record,
                         'message': "Foo",
                         }])
-            Note.write([note], {'message': "Bar"})
+            Note.write([note], {'unread': False})
             Note.read([note.id], ['message'])
             Note.delete([note])
 
@@ -178,7 +178,7 @@
                         'resource': record,
                         'message': "Foo",
                         }])
-            Note.write([note], {'message': "Bar"})
+            Note.write([note], {'unread': False})
             Note.read([note.id], ['message'])
             Note.delete([note])
 

Reply via email to