diff --git a/CHANGELOG b/CHANGELOG
index 235bafaa44..4656895b4c 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,4 @@
+* Add __access__ to Model
* Add route wrapper to allow null origin
* Add header grouping on report
* Protect trusted devices against brute force attack
diff --git a/doc/ref/models/models.rst b/doc/ref/models/models.rst
index 105ca11c89..f46da8526d 100644
--- a/doc/ref/models/models.rst
+++ b/doc/ref/models/models.rst
@@ -17,6 +17,11 @@ Class attributes are:
It contains the a unique name to reference the model throughout the
platform.
+.. attribute:: Model.__access__
+
+ A set that contains the names of relation field for which the access rights
+ are also checked for this model.
+
.. attribute:: Model.__rpc__
It contains a dictionary with method name as key and an instance of
diff --git a/trytond/backend/postgresql/init.sql b/trytond/backend/postgresql/init.sql
index f90ed1524d..184a6235c4 100644
--- a/trytond/backend/postgresql/init.sql
+++ b/trytond/backend/postgresql/init.sql
@@ -29,6 +29,7 @@ CREATE TABLE ir_model_field (
ttype VARCHAR,
help TEXT,
module VARCHAR,
+ "access" BOOL,
PRIMARY KEY(id),
FOREIGN KEY (model) REFERENCES ir_model(id) ON DELETE CASCADE
);
diff --git a/trytond/backend/sqlite/init.sql b/trytond/backend/sqlite/init.sql
index f330bde314..fbca012772 100644
--- a/trytond/backend/sqlite/init.sql
+++ b/trytond/backend/sqlite/init.sql
@@ -20,7 +20,8 @@ CREATE TABLE ir_model_field (
field_description VARCHAR,
ttype VARCHAR,
help TEXT,
- module VARCHAR
+ module VARCHAR,
+ "access" BOOLEAN
);
diff --git a/trytond/ir/model.py b/trytond/ir/model.py
index 0ebf8b588b..2b7e469372 100644
--- a/trytond/ir/model.py
+++ b/trytond/ir/model.py
@@ -243,6 +243,15 @@ class ModelField(ModelSQL, ModelView):
depends=['module'])
module = fields.Char('Module',
help="Module in which this field is defined.")
+ access = fields.Boolean(
+ "Access",
+ states={
+ 'invisible': ~Eval('relation'),
+ },
+ depends=['relation'],
+ help="If checked, the access right on the model of the field "
+ "is also tested against the relation of the field.")
+
_get_name_cache = Cache('ir.model.field.get_name')
@classmethod
@@ -273,6 +282,7 @@ class ModelField(ModelSQL, ModelView):
ir_model_field.relation.as_('relation'),
ir_model_field.module.as_('module'),
ir_model_field.help.as_('help'),
+ ir_model_field.access.as_('access'),
where=ir_model.model == model.__name__))
model_fields = {f['name']: f for f in cursor_dict(cursor)}
@@ -283,27 +293,32 @@ class ModelField(ModelSQL, ModelView):
relation = field.relation_name
else:
relation = None
+ access = field_name in model.__access__
if field_name not in model_fields:
cursor.execute(*ir_model_field.insert(
[ir_model_field.model, ir_model_field.name,
ir_model_field.field_description,
ir_model_field.ttype, ir_model_field.relation,
- ir_model_field.help, ir_model_field.module],
+ ir_model_field.help, ir_model_field.module,
+ ir_model_field.access],
[[
model_id, field_name,
field.string,
field._type, relation,
- field.help, module_name]]))
+ field.help, module_name,
+ access]]))
elif (model_fields[field_name]['field_description'] != field.string
or model_fields[field_name]['ttype'] != field._type
or model_fields[field_name]['relation'] != relation
- or model_fields[field_name]['help'] != field.help):
+ or model_fields[field_name]['help'] != field.help
+ or model_fields[field_name]['access'] != access):
cursor.execute(*ir_model_field.update(
[ir_model_field.field_description,
ir_model_field.ttype, ir_model_field.relation,
- ir_model_field.help],
- [field.string, field._type, relation, field.help],
+ ir_model_field.help, ir_model_field.access],
+ [field.string, field._type, relation, field.help,
+ access],
where=(ir_model_field.id
== model_fields[field_name]['id'])))
@@ -520,6 +535,18 @@ class ModelAccess(DeactivableMixin, ModelSQL, ModelView):
else:
return access
+ def fill_models(Model, models):
+ if Model.__name__ in models:
+ return
+ models.append(Model.__name__)
+ for field_name in Model.__access__:
+ field = getattr(Model, field_name)
+ fill_models(field.get_target(), models)
+ model2models = defaultdict(list)
+ for model in models:
+ fill_models(pool.get(model), model2models[model])
+
+ all_models = list(set(sum(model2models.values(), [])))
default = {'read': True, 'write': True, 'create': True, 'delete': True}
access = dict((m, default) for m in models)
cursor.execute(*model_access.join(ir_model, 'LEFT',
@@ -542,16 +569,24 @@ class ModelAccess(DeactivableMixin, ModelSQL, ModelView):
Max(Case(
(model_access.perm_delete == Literal(True), 1),
else_=0)),
- where=ir_model.model.in_(models)
+ where=ir_model.model.in_(all_models)
& (model_access.active == Literal(True))
& ((
(user_group.user == user)
& (group.active == Literal(True)))
| (model_access.group == Null)),
group_by=ir_model.model))
- access.update(dict(
- (m, {'read': r, 'write': w, 'create': c, 'delete': d})
- for m, r, w, c, d in cursor.fetchall()))
+ raw_access = {
+ m: {'read': r, 'write': w, 'create': c, 'delete': d}
+ for m, r, w, c, d in cursor.fetchall()}
+
+ for model in models:
+ access[model] = {
+ perm: max(
+ (raw_access[m][perm] for m in model2models[model]
+ if m in raw_access),
+ default=True)
+ for perm in ['read', 'write', 'create', 'delete']}
for model, maccess in access.items():
cls._get_access_cache.set((user, model), maccess)
return access
diff --git a/trytond/ir/rule.py b/trytond/ir/rule.py
index 88208e1869..7041dbb899 100644
--- a/trytond/ir/rule.py
+++ b/trytond/ir/rule.py
@@ -167,6 +167,25 @@ class Rule(ModelSQL, ModelView):
assert mode in cls.modes
+ model_names = []
+ model2field = defaultdict(list)
+
+ def update_model_names(Model, path=None):
+ if Model.__name__ in model_names:
+ return
+ model_names.append(Model.__name__)
+ if path:
+ model2field[Model.__name__].append(path)
+ for field_name in Model.__access__:
+ field = getattr(Model, field_name)
+ Target = field.get_target()
+ if path:
+ target_path = path + '.' + field_name
+ else:
+ target_path = field_name
+ update_model_names(Target, target_path)
+ update_model_names(pool.get(model_name))
+
cursor = transaction.connection.cursor()
user_id = transaction.user
# root user above constraint
@@ -179,7 +198,7 @@ class Rule(ModelSQL, ModelView):
).join(model,
condition=rule_group.model == model.id
).select(rule_table.id,
- where=(model.model == model_name)
+ where=(model.model.in_(model_names))
& (getattr(rule_group, 'perm_%s' % mode) == Literal(True))
& (rule_group.id.in_(
rule_group_group.join(
@@ -198,7 +217,7 @@ class Rule(ModelSQL, ModelView):
cursor.execute(*rule_group.join(model,
condition=rule_group.model == model.id
).select(rule_group.id,
- where=(model.model == model_name)
+ where=(model.model.in_(model_names))
& ~rule_group.id.in_(rule_table.select(rule_table.rule_group))
& rule_group.id.in_(rule_group_group.join(user_group,
condition=rule_group_group.group == user_group.group
@@ -215,6 +234,12 @@ class Rule(ModelSQL, ModelView):
assert rule.domain, ('Rule domain empty,'
'check if migration was done')
dom = decoder.decode(rule.domain)
+ target_model = rule.rule_group.model.model
+ if target_model in model2field:
+ target_dom = ['OR']
+ for field in model2field[target_model]:
+ target_dom.append((field, 'where', dom))
+ dom = target_dom
if rule.rule_group.global_p:
clause_global[rule.rule_group].append(dom)
else:
diff --git a/trytond/ir/view/model_field_form.xml b/trytond/ir/view/model_field_form.xml
index 3149cce230..3f32b718aa 100644
--- a/trytond/ir/view/model_field_form.xml
+++ b/trytond/ir/view/model_field_form.xml
@@ -14,6 +14,8 @@ this repository contains the full copyright notices and license terms. -->
+
+
diff --git a/trytond/ir/view/model_field_list.xml b/trytond/ir/view/model_field_list.xml
index d5b7263153..14d7eac74e 100644
--- a/trytond/ir/view/model_field_list.xml
+++ b/trytond/ir/view/model_field_list.xml
@@ -7,5 +7,6 @@ this repository contains the full copyright notices and license terms. -->
+
diff --git a/trytond/model/model.py b/trytond/model/model.py
index 78f564df37..3a83d4cde6 100644
--- a/trytond/model/model.py
+++ b/trytond/model/model.py
@@ -45,6 +45,7 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta):
'fields_get': RPC(cache=dict(days=1)),
'pre_validate': RPC(instantiate=0),
}
+ cls.__access__ = set()
# Copy fields and update depends
for attr in dir(cls):
diff --git a/trytond/tests/access.py b/trytond/tests/access.py
index 938395ea9e..d42e9b0817 100644
--- a/trytond/tests/access.py
+++ b/trytond/tests/access.py
@@ -23,8 +23,20 @@ class TestAccessRelate(ModelSQL):
value = fields.Integer("Value")
+class TestAccessModel(TestAccess):
+ "Test Access from Model"
+ __name__ = 'test.access.model'
+ access = fields.Many2One('test.access', "Access")
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.__access__.add('access')
+
+
def register(module):
Pool.register(
TestAccess,
TestAccessRelate,
+ TestAccessModel,
module=module, type_='model')
diff --git a/trytond/tests/rule.py b/trytond/tests/rule.py
index 9c9cdff7b1..9447f2f7a6 100644
--- a/trytond/tests/rule.py
+++ b/trytond/tests/rule.py
@@ -17,8 +17,23 @@ class TestRuleRelation(ModelSQL):
field = fields.Char("Field")
+class TestRuleModel(ModelSQL):
+ "Test Rule from Model"
+ __name__ = 'test.rule.model'
+ __access__ = 'test.rule'
+
+ name = fields.Char("Name")
+ rule = fields.Many2One('test.rule', "Rule")
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls.__access__.add('rule')
+
+
def register(module):
Pool.register(
TestRule,
TestRuleRelation,
+ TestRuleModel,
module=module, type_='model')
diff --git a/trytond/tests/test_access.py b/trytond/tests/test_access.py
index a33a400cfb..eebea8cfde 100644
--- a/trytond/tests/test_access.py
+++ b/trytond/tests/test_access.py
@@ -18,11 +18,13 @@ class _ModelAccessTestCase(unittest.TestCase):
def setUpClass(cls):
activate_module('tests')
+ model_name = model_access_name = 'test.access'
+
@property
def model(self):
pool = Pool()
Model = pool.get('ir.model')
- model, = Model.search([('model', '=', 'test.access')])
+ model, = Model.search([('model', '=', self.model_access_name)])
return model
@property
@@ -42,7 +44,7 @@ class _ModelAccessTestCase(unittest.TestCase):
def test_access_empty(self):
"Test access without model access"
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
self._assert(record)
@@ -52,7 +54,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test access without group"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -67,7 +69,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test no access without group"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -82,7 +84,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test one access with groups"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -102,7 +104,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test one access without group"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -122,7 +124,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test all access with groups"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -142,7 +144,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test no access with groups"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -162,7 +164,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test one access with group"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -177,7 +179,7 @@ class _ModelAccessTestCase(unittest.TestCase):
"Test no access with group"
pool = Pool()
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
ModelAccess.create([{
'model': self.model.id,
@@ -193,7 +195,7 @@ class _ModelAccessTestCase(unittest.TestCase):
pool = Pool()
Group = pool.get('res.group')
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
group, = Group.create([{'name': 'Test'}])
ModelAccess.create([{
@@ -215,7 +217,7 @@ class _ModelAccessTestCase(unittest.TestCase):
pool = Pool()
Group = pool.get('res.group')
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
group, = Group.create([{'name': 'Test'}])
ModelAccess.create([{
@@ -237,7 +239,7 @@ class _ModelAccessTestCase(unittest.TestCase):
pool = Pool()
Group = pool.get('res.group')
ModelAccess = pool.get('ir.model.access')
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
group, = Group.create([{'name': 'Test'}])
ModelAccess.create([{
@@ -259,13 +261,13 @@ class ModelAccessReadTestCase(_ModelAccessTestCase):
def _assert(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
TestAccess.read([record.id], ['field1'])
TestAccess.search([])
def _assert_raises(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
with self.assertRaises(AccessError):
TestAccess.read([record.id], ['field1'])
with self.assertRaises(AccessError):
@@ -275,7 +277,7 @@ class ModelAccessReadTestCase(_ModelAccessTestCase):
def test_access_relate_empty(self):
"Test access on search relate without model access"
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
record, = TestAccess.create([{}])
TestAccess.read([record.id], ['relate.value'])
@@ -286,7 +288,7 @@ class ModelAccessReadTestCase(_ModelAccessTestCase):
def test_access_relate(self):
"Test access on search relate"
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
Model = pool.get('ir.model')
ModelAccess = pool.get('ir.model.access')
record, = TestAccess.create([{}])
@@ -307,7 +309,7 @@ class ModelAccessReadTestCase(_ModelAccessTestCase):
def test_no_access_relate(self):
"Test no access on search relate"
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
Model = pool.get('ir.model')
ModelAccess = pool.get('ir.model.access')
record, = TestAccess.create([{}])
@@ -333,12 +335,12 @@ class ModelAccessWriteTestCase(_ModelAccessTestCase):
def _assert(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
TestAccess.write([record], {})
def _assert_raises(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
with self.assertRaises(AccessError):
TestAccess.write([record], {})
@@ -348,12 +350,12 @@ class ModelAccessCreateTestCase(_ModelAccessTestCase):
def _assert(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
TestAccess.create([{}])
def _assert_raises(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
with self.assertRaises(AccessError):
TestAccess.create([{}])
@@ -363,16 +365,35 @@ class ModelAccessDeleteTestCase(_ModelAccessTestCase):
def _assert(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
TestAccess.delete([record])
def _assert_raises(self, record):
pool = Pool()
- TestAccess = pool.get('test.access')
+ TestAccess = pool.get(self.model_name)
with self.assertRaises(AccessError):
TestAccess.delete([record])
+class ModelAccessModelTestCase(_ModelAccessTestCase):
+ model_name = 'test.access.model'
+ _perm = 'perm_read'
+
+ def _assert(self, record):
+ pool = Pool()
+ TestAccess = pool.get(self.model_name)
+ TestAccess.read([record.id], ['field1'])
+ TestAccess.search([])
+
+ def _assert_raises(self, record):
+ pool = Pool()
+ TestAccess = pool.get(self.model_name)
+ with self.assertRaises(AccessError):
+ TestAccess.read([record.id], ['field1'])
+ with self.assertRaises(AccessError):
+ TestAccess.search([])
+
+
class _ModelFieldAccessTestCase(unittest.TestCase):
_perm = None
@@ -926,6 +947,8 @@ def suite():
).loadTestsFromTestCase(ModelAccessCreateTestCase))
suite_.addTests(unittest.TestLoader(
).loadTestsFromTestCase(ModelAccessDeleteTestCase))
+ suite_.addTests(unittest.TestLoader(
+ ).loadTestsFromTestCase(ModelAccessModelTestCase))
suite_.addTests(unittest.TestLoader(
).loadTestsFromTestCase(ModelFieldAccessReadTestCase))
suite_.addTests(unittest.TestLoader(
diff --git a/trytond/tests/test_rule.py b/trytond/tests/test_rule.py
index fcd7adfbee..942c86e782 100644
--- a/trytond/tests/test_rule.py
+++ b/trytond/tests/test_rule.py
@@ -623,6 +623,63 @@ class ModelRuleTestCase(unittest.TestCase):
value, = TestRule.read([non_deletable.id], ['_delete'])
self.assertEqual(value['_delete'], False)
+ @with_transaction()
+ def test_model_with_rule(self):
+ "Test model with rule"
+ pool = Pool()
+ TestRule = pool.get('test.rule')
+ TestRuleModel = pool.get('test.rule.model')
+ 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,
+ 'perm_create': False,
+ 'perm_write': False,
+ 'perm_delete': False,
+ 'rules': [('create', [{
+ 'domain': json.dumps(
+ [('field', '!=', 'foo')]),
+ }])],
+ }])
+ rule, = TestRule.create([{'field': 'bar'}])
+ test, = TestRuleModel.create([{'rule': rule.id, 'name': 'foo'}])
+
+ TestRuleModel.read([test.id], ['name'])
+
+ @with_transaction()
+ def test_model_with_rule_fail(self):
+ "Test model with rule fail"
+ pool = Pool()
+ TestRule = pool.get('test.rule')
+ TestRuleModel = pool.get('test.rule.model')
+ 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,
+ 'perm_create': False,
+ 'perm_write': False,
+ 'perm_delete': False,
+ 'rules': [('create', [{
+ 'domain': json.dumps(
+ [('field', '!=', 'foo')]),
+ }])],
+ }])
+ rule, = TestRule.create([{'field': 'foo'}])
+ test, = TestRuleModel.create([{'rule': rule.id, 'name': 'foo'}])
+
+ with self.assertRaisesRegex(AccessError, "Field different from foo"):
+ TestRuleModel.read([test.id], ['name'])
+
def suite():
suite_ = unittest.TestSuite()
diff --git a/trytond/tests/test_tryton.py b/trytond/tests/test_tryton.py
index 42e0c97604..c23758108f 100644
--- a/trytond/tests/test_tryton.py
+++ b/trytond/tests/test_tryton.py
@@ -246,6 +246,23 @@ class ModuleTestCase(unittest.TestCase):
msg="Wrong '%s' type for _rec_name of %s'" % (
field._type, mname))
+ @with_transaction()
+ def test_model__access__(self):
+ "Test existing model __access__"
+ pool = Pool()
+ for mname, Model in pool.iterobject():
+ if not isregisteredby(Model, self.module):
+ continue
+ for field_name in Model.__access__:
+ self.assertIn(field_name, Model._fields.keys(),
+ msg="Wrong __access__ '%s' for %s" % (field_name, mname))
+ field = Model._fields[field_name]
+ Target = field.get_target()
+ self.assertTrue(
+ Target,
+ msg='Missing target for __access__ "%s" of %s' % (
+ field_name, mname))
+
@with_transaction()
def test_view(self):
'Test validity of all views of the module'
@@ -529,10 +546,22 @@ class ModuleTestCase(unittest.TestCase):
'Test missing default model access'
pool = Pool()
Access = pool.get('ir.model.access')
- no_groups = {a.model.name for a in Access.search([
+ no_groups = {a.model.model for a in Access.search([
('group', '=', None),
])}
- with_groups = {a.model.name for a in Access.search([
+
+ def has_access(Model, models):
+ if Model.__name__ in models:
+ return True
+ for field_name in Model.__access__:
+ Target = Model._fields[field_name].get_target()
+ if has_access(Target, models):
+ return True
+ for mname, Model in pool.iterobject():
+ if has_access(Model, no_groups):
+ no_groups.add(mname)
+
+ with_groups = {a.model.model for a in Access.search([
('group', '!=', None),
])}