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