diff --git a/base_user_role_extended/README.rst b/base_user_role_extended/README.rst new file mode 100644 index 0000000..5715f0f --- /dev/null +++ b/base_user_role_extended/README.rst @@ -0,0 +1,116 @@ +======================= +Base User Role Extended +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:01352f37bacf0b64db54090d6d666220a4ec5a811c907507e0785f75eca89a19 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/18.0/base_user_role_extended + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-18-0/server-backend-18-0-base_user_role_extended + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-backend&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the ``base_user_role`` module to enforce strict +role-based access control. + +It overrides the access rights evaluation to ensure that for model +access rights, Odoo ignores standard user group assignments and +considers only those groups associated with the user's active, enabled +roles. + +This ensures a robust separation of concerns where role configurations +supersede implicit or overlapping group permissions. + + **⚠️ Important Installation Note** + + Installing this module will **recursively add all inherited model + accesses to the role's associated group access rights**, even for the + **existing roles** already present in the system. This means it might + grant new access permissions to those existing roles based on their + assigned groups' inheritance. + + **Example:** If you have an existing role "Sales Manager" that + includes the standard group "Sales / Manager", and that standard + group inherits from "Sales / User", installing this module will + automatically copy all the model access rights from *both* "Sales / + Manager" and "Sales / User" directly onto the role's associated + group. If the role was previously missing some of these inherited + permissions, it will now possess them. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +1. Install this module which depends on ``base_user_role``. +2. Go to **Settings > Users & Companies > Roles**. +3. Create or configure a role by assigning the necessary standard Odoo + groups to it. +4. Assign the configured role to a user and ensure it is enabled. +5. The user's CRUD access to models will now be strictly constrained to + only the permissions explicitly granted by their active roles, + ignoring any other direct group memberships. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* CIT Services + +Contributors +------------ + +- CIT Services +- Solomon Prabu s.prabu@cit-services.eu + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_user_role_extended/__init__.py b/base_user_role_extended/__init__.py new file mode 100644 index 0000000..1d353d7 --- /dev/null +++ b/base_user_role_extended/__init__.py @@ -0,0 +1,2 @@ +from .hooks import post_init_hook +from . import models diff --git a/base_user_role_extended/__manifest__.py b/base_user_role_extended/__manifest__.py new file mode 100644 index 0000000..2e9a6f4 --- /dev/null +++ b/base_user_role_extended/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Base User Role Extended", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "Extends user roles with additional access control features", + "author": "CIT Services, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-backend", + "license": "LGPL-3", + "depends": ["base_user_role"], + "data": [ + "views/res_users_views.xml", + ], + "installable": True, + "post_init_hook": "post_init_hook", +} diff --git a/base_user_role_extended/hooks.py b/base_user_role_extended/hooks.py new file mode 100644 index 0000000..4f660d0 --- /dev/null +++ b/base_user_role_extended/hooks.py @@ -0,0 +1,15 @@ +# Copyright 2026 CIT Services (https://www.cit-services.eu). +# @author Solomon Prabu +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +logger = logging.getLogger(__name__) + + +def post_init_hook(env): + roles = env["res.users.role"].search([]) + if roles: + roles._update_role_model_access() + logger.info("Updated model access for %d roles", len(roles)) + else: + logger.info("No roles found") diff --git a/base_user_role_extended/models/__init__.py b/base_user_role_extended/models/__init__.py new file mode 100644 index 0000000..6a34f6e --- /dev/null +++ b/base_user_role_extended/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_users_role +from . import res_users +from . import ir_model_access +from . import ir_actions_server diff --git a/base_user_role_extended/models/ir_actions_server.py b/base_user_role_extended/models/ir_actions_server.py new file mode 100644 index 0000000..63d492e --- /dev/null +++ b/base_user_role_extended/models/ir_actions_server.py @@ -0,0 +1,36 @@ +from odoo import models + + +class IrActionsServer(models.Model): + _inherit = "ir.actions.server" + + def run(self): + """ + Adapts server action execution for role-based users. + * Natively, actions without explicit groups crash + if the user lacks 'write' access. + * Strict role policies often remove 'write' access, breaking basic UI menus. + * This cleanly injects the user's role groups into the ORM cache temporarily. + * `super().run()` reads the cache, bypassing the hardcoded 'write' + check natively. + * Avoids overriding the large core method or using stack frame workarounds. + """ + role_group_ids = self.env.user.with_context(role=True)._get_group_ids() + + if role_group_ids: + role_groups_tuple = tuple(role_group_ids) + for action in self.sudo(): + if not action.groups_id: + # Inject into cache safely without triggering a database write. + self.env.cache.set( + action, action._fields["groups_id"], role_groups_tuple + ) + + try: + return super().run() + finally: + if role_group_ids: + # Clean up the injected cache to maintain perfect environment state + for action in self.sudo(): + if self.env.cache.contains(action, action._fields["groups_id"]): + self.env.cache.remove(action, action._fields["groups_id"]) diff --git a/base_user_role_extended/models/ir_model_access.py b/base_user_role_extended/models/ir_model_access.py new file mode 100644 index 0000000..8de4912 --- /dev/null +++ b/base_user_role_extended/models/ir_model_access.py @@ -0,0 +1,120 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models, tools +from odoo.tools import SQL + + +class IrModelAccess(models.Model): + _inherit = "ir.model.access" + + @api.model + @tools.ormcache("self.env.uid", "mode") + def _get_allowed_models(self, mode="read"): + """ + Override to enforce exclusive role-based model access. + + When the current user has active roles and is not a bypass user: + - Query ir.model.access using ONLY the role-associated group IDs + (populated by ResUsersRole._update_role_model_access). + - This makes the role group's access rights the sole authority, + superseding every other group the user belongs to. + - Global access rules (group_id IS NULL) are still respected as the + baseline, matching standard Odoo behaviour. + + For bypass users (admin/root) or users with no active roles: + - Delegates to super() → standard all-groups resolution. + """ + if self.env.user.bypass_role_policy: + return super()._get_allowed_models(mode) + + # Check if role_group_ids is passed in context to bypass DB/compute + if "role_group_ids" in self.env.context: + role_group_ids = tuple(self.env.context["role_group_ids"]) + if not role_group_ids: + return super()._get_allowed_models(mode) + else: + role_group_ids = tuple( + self.env.user.with_context(role=True)._get_group_ids() + ) + + if not role_group_ids: + return super()._get_allowed_models(mode) + + if role_group_ids: + # Strictly use only the explicitly assigned role groups + role_group_ids = tuple(role_group_ids) + # Query ir.model.access restricted strictly to the explicit + # role groups + global access + self.flush_model() + rows = self.env.execute_query( + SQL( + """ + SELECT m.model + FROM ir_model_access a + JOIN ir_model m ON (m.id = a.model_id) + WHERE a.perm_%s + AND a.active + AND ( + a.group_id IS NULL OR + a.group_id IN %s + ) + GROUP BY m.model + """, + SQL(mode), + role_group_ids or (None,), + ) + ) + return frozenset(row[0] for row in rows) + + # Handle access rights changes from the respective groups, + # such as create, update, and deletion of access rights + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._update_associated_roles() + return records + + def write(self, vals): + res = super().write(vals) + self._update_associated_roles() + return res + + def unlink(self): + if self.env.context.get("updating_role_model_access") or self.env.context.get( + "install_mode" + ): + return super().unlink() + + roles = self._get_associated_roles() + res = super().unlink() + if roles: + roles.with_context( + updating_role_model_access=True + )._update_role_model_access() + return res + + def _get_associated_roles(self): + """ + Find roles where the trans_implied_ids includes the group_ids + of the current model access records. + """ + group_ids = self.mapped("group_id").ids + if not group_ids: + return self.env["res.users.role"].browse() + + roles = self.env["res.users.role"].search([]) + return roles.filtered( + lambda r: not set(r.trans_implied_ids.ids).isdisjoint(group_ids) + ) + + def _update_associated_roles(self): + if self.env.context.get("updating_role_model_access") or self.env.context.get( + "install_mode" + ): + return + roles = self._get_associated_roles() + if roles: + roles.with_context( + updating_role_model_access=True + )._update_role_model_access() diff --git a/base_user_role_extended/models/res_users.py b/base_user_role_extended/models/res_users.py new file mode 100644 index 0000000..197ee16 --- /dev/null +++ b/base_user_role_extended/models/res_users.py @@ -0,0 +1,37 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models, tools + + +class ResUsers(models.Model): + _inherit = "res.users" + + bypass_role_policy = fields.Boolean( + compute="_compute_bypass_role_policy", + help="If checked, this record bypasses role-based" + "view combination evaluation checks", + ) + + def _compute_bypass_role_policy(self): + admin = self.env.ref("base.user_admin", raise_if_not_found=False) + root = self.env.ref("base.user_root", raise_if_not_found=False) + for user in self: + user.bypass_role_policy = user in (admin, root) + + def set_groups_from_roles(self, force=False): + # Admin / root users should not have their actual groups replaced by roles + # because they bypass role policy and rely on their standard groups. + users_to_update = self.filtered(lambda u: not u.bypass_role_policy) + if users_to_update: + return super(ResUsers, users_to_update).set_groups_from_roles(force=force) + return True + + @tools.ormcache("self.env.uid", "self.env.context.get('role')") + def _get_group_ids(self): + if self.env.context.get("role"): + roles = self.sudo()._get_enabled_roles().mapped("role_id") + if roles: + return frozenset(roles.mapped("group_id")._ids) + return frozenset() + return super()._get_group_ids() diff --git a/base_user_role_extended/models/res_users_role.py b/base_user_role_extended/models/res_users_role.py new file mode 100644 index 0000000..101f123 --- /dev/null +++ b/base_user_role_extended/models/res_users_role.py @@ -0,0 +1,126 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class ResUsersRole(models.Model): + _name = "res.users.role" + _inherit = ["res.users.role"] + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._update_role_model_access() + return records + + def unlink(self): + recs = ( + self.sudo() + if self._bypass_rules() or self.env.user.bypass_role_policy + else self + ) + return super(ResUsersRole, recs).unlink() + + def write(self, vals): + update_access = "implied_ids" in vals + res = super().write(vals) + if update_access: + self._update_role_model_access() + return res + + def _update_role_model_access(self, perm_fields=None): + """ + Synchronize the access rights from the associated user groups + into the role's model access records. + """ + all_perm_fields = self.collect_all_perm_fields(perm_fields) + + # Precompute the trans_implied_ids before clearing the ORM cache + precomputed_groups = {role.id: role.sudo().trans_implied_ids for role in self} + + self.invalidate_recordset(["implied_ids"]) + self.mapped("group_id").invalidate_recordset(["implied_ids"]) + for role in self: + role._clear_existing_model_access() + + # Inject the precomputed groups into the context to skip re-computation + role_ctx = role.with_context( + precomputed_role_groups=precomputed_groups[role.id] + ) + access_records = role_ctx.model_access_ids + model_permissions = self.parse_model_access( + access_records, perm_fields=all_perm_fields + ) + + ir_access_vals = role._prepare_model_access_vals(model_permissions) + if ir_access_vals: + self.env["ir.model.access"].with_context( + updating_role_model_access=True + ).create(ir_access_vals) + + def collect_all_perm_fields(self, perm_fields=None): + default_perm_fields = { + "perm_read": False, + "perm_write": False, + "perm_create": False, + "perm_unlink": False, + } + if perm_fields: + default_perm_fields.update(perm_fields) + return default_perm_fields + + @api.depends("implied_ids", "implied_ids.model_access") + def _compute_model_access_ids(self): + res = super()._compute_model_access_ids() + for rec in self: + rec.model_access_ids = rec._get_implied_model_access_records() + rec.model_access_count = len(rec.model_access_ids) + return res + + def _get_implied_model_access_records(self): + self.ensure_one() + if "precomputed_role_groups" in self.env.context: + return self.env.context["precomputed_role_groups"].mapped("model_access") + return self.sudo().trans_implied_ids.model_access + + def _clear_existing_model_access(self): + self.ensure_one() + self.env["ir.model.access"].with_context( + updating_role_model_access=True + ).search([("group_id", "=", self.group_id.id)]).unlink() + + def _prepare_model_access_vals(self, model_permissions): + self.ensure_one() + ir_access_vals = [] + for model_rec, perms in model_permissions.items(): + vals = { + "name": f"{model_rec.model}", + "model_id": model_rec.id, + "group_id": self.group_id.id, + } + vals.update(perms) + ir_access_vals.append(vals) + return ir_access_vals + + def parse_model_access(self, model_access, perm_fields): + model_permissions = {} + for access in model_access: + model_rec = access.model_id + if model_rec not in model_permissions: + model_permissions[model_rec] = perm_fields.copy() + for field_name in perm_fields: + model_permissions[model_rec][field_name] |= getattr(access, field_name) + return model_permissions + + +class ResUsersRoleLine(models.Model): + _inherit = "res.users.role.line" + + def unlink(self): + recs = ( + self.sudo() + if self.role_id._bypass_rules() or self.env.user.bypass_role_policy + else self + ) + return super(ResUsersRoleLine, recs).unlink() diff --git a/base_user_role_extended/pyproject.toml b/base_user_role_extended/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/base_user_role_extended/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_user_role_extended/readme/CONTRIBUTORS.md b/base_user_role_extended/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..85c4cc3 --- /dev/null +++ b/base_user_role_extended/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- CIT Services +- Solomon Prabu \ No newline at end of file diff --git a/base_user_role_extended/readme/DESCRIPTION.md b/base_user_role_extended/readme/DESCRIPTION.md new file mode 100644 index 0000000..794eabd --- /dev/null +++ b/base_user_role_extended/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +This module extends the `base_user_role` module to enforce strict +role-based access control. + +It overrides the access rights evaluation to ensure that for model +access rights, Odoo ignores standard user group assignments and +considers only those groups associated with the user's active, enabled +roles. + +This ensures a robust separation of concerns where role configurations +supersede implicit or overlapping group permissions. + +> **⚠️ Important Installation Note** +> +> Installing this module will **recursively add all inherited model accesses to the role's associated group access rights**, even for the **existing roles** already present in the system. This means it might grant new access permissions to those existing roles based on their assigned groups' inheritance. +> +> **Example:** +> If you have an existing role "Sales Manager" that includes the standard group "Sales / Manager", and that standard group inherits from "Sales / User", installing this module will automatically copy all the model access rights from *both* "Sales / Manager" and "Sales / User" directly onto the role's associated group. If the role was previously missing some of these inherited permissions, it will now possess them. diff --git a/base_user_role_extended/readme/USAGE.md b/base_user_role_extended/readme/USAGE.md new file mode 100644 index 0000000..ce3cae1 --- /dev/null +++ b/base_user_role_extended/readme/USAGE.md @@ -0,0 +1,10 @@ +To use this module, you need to: + +1. Install this module which depends on `base_user_role`. +2. Go to **Settings \> Users & Companies \> Roles**. +3. Create or configure a role by assigning the necessary standard Odoo + groups to it. +4. Assign the configured role to a user and ensure it is enabled. +5. The user's CRUD access to models will now be strictly constrained to + only the permissions explicitly granted by their active roles, + ignoring any other direct group memberships. diff --git a/base_user_role_extended/static/description/index.html b/base_user_role_extended/static/description/index.html new file mode 100644 index 0000000..5bcbe21 --- /dev/null +++ b/base_user_role_extended/static/description/index.html @@ -0,0 +1,461 @@ + + + + + +Base User Role Extended + + + +
+

Base User Role Extended

+ + +

Beta License: LGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

+

This module extends the base_user_role module to enforce strict +role-based access control.

+

It overrides the access rights evaluation to ensure that for model +access rights, Odoo ignores standard user group assignments and +considers only those groups associated with the user’s active, enabled +roles.

+

This ensures a robust separation of concerns where role configurations +supersede implicit or overlapping group permissions.

+
+

⚠️ Important Installation Note

+

Installing this module will recursively add all inherited model +accesses to the role’s associated group access rights, even for the +existing roles already present in the system. This means it might +grant new access permissions to those existing roles based on their +assigned groups’ inheritance.

+

Example: If you have an existing role “Sales Manager” that +includes the standard group “Sales / Manager”, and that standard +group inherits from “Sales / User”, installing this module will +automatically copy all the model access rights from both “Sales / +Manager” and “Sales / User” directly onto the role’s associated +group. If the role was previously missing some of these inherited +permissions, it will now possess them.

+
+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

+
    +
  1. Install this module which depends on base_user_role.
  2. +
  3. Go to Settings > Users & Companies > Roles.
  4. +
  5. Create or configure a role by assigning the necessary standard Odoo +groups to it.
  6. +
  7. Assign the configured role to a user and ensure it is enabled.
  8. +
  9. The user’s CRUD access to models will now be strictly constrained to +only the permissions explicitly granted by their active roles, +ignoring any other direct group memberships.
  10. +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • CIT Services
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-backend project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/base_user_role_extended/tests/__init__.py b/base_user_role_extended/tests/__init__.py new file mode 100644 index 0000000..9119885 --- /dev/null +++ b/base_user_role_extended/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_user_role_extended diff --git a/base_user_role_extended/tests/test_base_user_role_extended.py b/base_user_role_extended/tests/test_base_user_role_extended.py new file mode 100644 index 0000000..6ef7f53 --- /dev/null +++ b/base_user_role_extended/tests/test_base_user_role_extended.py @@ -0,0 +1,377 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + + +class TestBaseUserRoleExtended(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.user_admin = cls.env.ref("base.user_admin") + cls.user_root = cls.env.ref("base.user_root") + cls.test_user = cls.env["res.users"].create( + { + "name": "Test Role User", + "login": "test_role_user", + } + ) + cls.model_res_partner = cls.env.ref("base.model_res_partner") + cls.model_res_users = cls.env.ref("base.model_res_users") + + cls.group_partner_manager = cls.env["res.groups"].create( + {"name": "Partner Manager"} + ) + cls.env["ir.model.access"].create( + { + "name": "partner manager access", + "model_id": cls.model_res_partner.id, + "group_id": cls.group_partner_manager.id, + "perm_read": True, + "perm_write": True, + "perm_create": False, + "perm_unlink": False, + } + ) + + cls.group_mixed = cls.env["res.groups"].create({"name": "Mixed Access"}) + cls.env["ir.model.access"].create( + { + "name": "mixed users access", + "model_id": cls.model_res_users.id, + "group_id": cls.group_mixed.id, + "perm_read": True, + "perm_write": False, + "perm_create": False, + "perm_unlink": False, + } + ) + cls.env["ir.model.access"].create( + { + "name": "mixed partner access", + "model_id": cls.model_res_partner.id, + "group_id": cls.group_mixed.id, + "perm_read": False, + "perm_write": False, + "perm_create": True, + "perm_unlink": False, + } + ) + + def test_compute_bypass_role_policy(self): + """Test that user_admin and user_root bypass the role policy, + but normal users don't.""" + self.assertTrue(self.user_admin.bypass_role_policy) + self.assertTrue(self.user_root.bypass_role_policy) + self.assertFalse(self.test_user.bypass_role_policy) + + def test_role_creation_and_access_sync(self): + """Test creating a role and ensuring its model + access rights are synced correctly.""" + # Create a role that implies our two groups + role = self.env["res.users.role"].create( + { + "name": "Test Manager Role", + "implied_ids": [ + (4, self.group_partner_manager.id), + (4, self.group_mixed.id), + ], + } + ) + # Check that the role's underlying group now has the merged access rights + access_partner = self.env["ir.model.access"].search( + [ + ("group_id", "=", role.group_id.id), + ("model_id", "=", self.model_res_partner.id), + ] + ) + self.assertTrue(access_partner) + # Should merge perm_read and perm_write from group_partner_manager, + # and perm_create from group_mixed + self.assertTrue(access_partner.perm_read) + self.assertTrue(access_partner.perm_write) + self.assertTrue(access_partner.perm_create) + self.assertFalse(access_partner.perm_unlink) + + access_users = self.env["ir.model.access"].search( + [ + ("group_id", "=", role.group_id.id), + ("model_id", "=", self.model_res_users.id), + ] + ) + self.assertTrue(access_users) + self.assertTrue(access_users.perm_read) + self.assertFalse(access_users.perm_write) + + role.write({"name": "Renamed Role"}) + self.assertEqual(len(access_partner), 1) + + role.write({"implied_ids": [(3, self.group_mixed.id)]}) + # Users model access should be removed because it was only in group_mixed + access_users = self.env["ir.model.access"].search( + [ + ("group_id", "=", role.group_id.id), + ("model_id", "=", self.model_res_users.id), + ] + ) + self.assertFalse(access_users) + + def test_ir_model_access_get_allowed_models(self): + """Test the _get_allowed_models override using the test user and roles.""" + self.test_user.role_line_ids.unlink() + + # Test cache and standard behavior + self.env.registry.clear_cache() + allowed_models_no_role = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("read") + ) + self.assertIn("res.partner", allowed_models_no_role) + + # Give the test user our role containing only group_mixed + role = self.env["res.users.role"].create( + { + "name": "Read Users Role", + "implied_ids": [(4, self.group_mixed.id)], + } + ) + self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role.id, + } + ) + + self.env.registry.clear_cache() + + allowed_models_with_role = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("read") + ) + + # The user should have access to 'res.users' for read + self.assertIn("res.users", allowed_models_with_role) + # But 'res.partner' should NOT be in the allowed models for read! + self.assertNotIn("res.partner", allowed_models_with_role) + + # Test bypass user (admin) + self.env.registry.clear_cache() + allowed_models_admin = ( + self.env["ir.model.access"] + .with_user(self.user_admin) + ._get_allowed_models("read") + ) + self.assertIn("res.partner", allowed_models_admin) + + # Give admin the same role, but they should bypass it + self.env["res.users.role.line"].create( + { + "user_id": self.user_admin.id, + "role_id": role.id, + } + ) + self.env.registry.clear_cache() + allowed_models_admin_bypassed = ( + self.env["ir.model.access"] + .with_user(self.user_admin) + ._get_allowed_models("read") + ) + self.assertIn("res.partner", allowed_models_admin_bypassed) + + def test_user_role_add_remove_access(self): + """Test adding a role with access and then, + removing it to ensure access is lost.""" + + self.test_user.role_line_ids.unlink() + + role = self.env["res.users.role"].create( + { + "name": "Dynamic Access Role", + "implied_ids": [(4, self.group_partner_manager.id)], + } + ) + + # Assign role to user + self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role.id, + } + ) + + self.env.registry.clear_cache() + + # Verify user initially has Read & Write access + allowed_read = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("read") + ) + allowed_write = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("write") + ) + self.assertIn( + "res.partner", allowed_read, "User should have read access initially." + ) + self.assertIn( + "res.partner", allowed_write, "User should have write access initially." + ) + + role.write({"implied_ids": [(3, self.group_partner_manager.id)]}) + + self.env.registry.clear_cache() + + # Verify user lost access + allowed_read_after = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("read") + ) + allowed_write_after = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("write") + ) + self.assertNotIn( + "res.partner", allowed_read_after, "User should have lost read access." + ) + self.assertNotIn( + "res.partner", allowed_write_after, "User should have lost write access." + ) + + def test_hooks(self): + from ..hooks import post_init_hook + + post_init_hook(self.env) + + # Also test with no roles + self.env["res.users.role"].search([]).unlink() + post_init_hook(self.env) + + def test_ir_model_access_context(self): + """Test the context bypass in _get_allowed_models""" + allowed_models = ( + self.env["ir.model.access"] + .with_user(self.test_user) + .with_context(role_group_ids=[self.group_partner_manager.id]) + ._get_allowed_models("read") + ) + self.assertIn("res.partner", allowed_models) + allowed_models_empty = ( + self.env["ir.model.access"] + .with_user(self.test_user) + .with_context(role_group_ids=[]) + ._get_allowed_models("read") + ) + self.assertIn("res.partner", allowed_models_empty) + + def test_ir_model_access_crud(self): + """Test write and unlink on ir.model.access syncing with roles""" + role = self.env["res.users.role"].create( + { + "name": "CRUD Role", + "implied_ids": [(4, self.group_partner_manager.id)], + } + ) + + access = self.env["ir.model.access"].search( + [ + ("group_id", "=", self.group_partner_manager.id), + ("model_id", "=", self.model_res_partner.id), + ] + ) + + # Write + access.write({"perm_create": True}) + role_access = self.env["ir.model.access"].search( + [ + ("group_id", "=", role.group_id.id), + ("model_id", "=", self.model_res_partner.id), + ] + ) + self.assertTrue(role_access.perm_create) + + # Unlink + access.unlink() + role_access = self.env["ir.model.access"].search( + [ + ("group_id", "=", role.group_id.id), + ("model_id", "=", self.model_res_partner.id), + ] + ) + self.assertFalse(role_access) + + def test_ir_model_access_no_groups(self): + """Test _get_associated_roles with no groups""" + access = self.env["ir.model.access"].create( + { + "name": "Test access no group", + "model_id": self.model_res_partner.id, + } + ) + # unlink should pass through without finding associated roles + access.unlink() + self.assertFalse(access.exists()) + + def test_res_users_role_perm_fields(self): + """Test collect_all_perm_fields and without precomputed groups context""" + role = self.env["res.users.role"].create( + { + "name": "Perm Fields Role", + } + ) + role._update_role_model_access(perm_fields={"perm_read": True}) + + # Test without context for _get_implied_model_access_records + records = role._get_implied_model_access_records() + self.assertFalse(records) + + def test_ir_actions_server_role_bypass(self): + """Test that server actions natively bypass strict write checks + when the user has a strict role.""" + + self.test_user.role_line_ids.unlink() + + # 1. Give test_user a role with ONLY read access to res.users (group_mixed) + role = self.env["res.users.role"].create( + { + "name": "Read Users Role", + "implied_ids": [(4, self.group_mixed.id)], + } + ) + self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role.id, + } + ) + self.env.registry.clear_cache() + + # 2. Verify test_user has NO write access to res.users natively + allowed_write = ( + self.env["ir.model.access"] + .with_user(self.test_user) + ._get_allowed_models("write") + ) + self.assertNotIn("res.users", allowed_write) + + # 3. Create a server action on res.users with NO groups + action = self.env["ir.actions.server"].create( + { + "name": "Test Action", + "model_id": self.model_res_users.id, + "state": "code", + "code": 'action = {"type": "ir.actions.act_window_close"}', + } + ) + + # 4. Without our override, this would crash with an AccessError natively + # since it hardchecks check_access('write') when groups_id is empty. + # With our override, it should run seamlessly. + result = action.with_user(self.test_user).run() + self.assertEqual(result.get("type"), "ir.actions.act_window_close") diff --git a/base_user_role_extended/views/res_users_views.xml b/base_user_role_extended/views/res_users_views.xml new file mode 100644 index 0000000..77445c7 --- /dev/null +++ b/base_user_role_extended/views/res_users_views.xml @@ -0,0 +1,17 @@ + + + + + + res.users.form.view_access_rules + res.users + + + + + + + +