Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions base_user_role_extended/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OCA/server-backend/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 <https://github.com/OCA/server-backend/issues/new?body=module:%20base_user_role_extended%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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

Credits
=======

Authors
-------

* CIT Services

Contributors
------------

- CIT Services <cit-services.eu>
- 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 <https://github.com/OCA/server-backend/tree/18.0/base_user_role_extended>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
2 changes: 2 additions & 0 deletions base_user_role_extended/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .hooks import post_init_hook
from . import models
18 changes: 18 additions & 0 deletions base_user_role_extended/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
}
15 changes: 15 additions & 0 deletions base_user_role_extended/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright 2026 CIT Services (https://www.cit-services.eu).
# @author Solomon Prabu <s.prabu@cit-services.eu>
# 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")
4 changes: 4 additions & 0 deletions base_user_role_extended/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import res_users_role
from . import res_users
from . import ir_model_access
from . import ir_actions_server
36 changes: 36 additions & 0 deletions base_user_role_extended/models/ir_actions_server.py
Original file line number Diff line number Diff line change
@@ -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"])
120 changes: 120 additions & 0 deletions base_user_role_extended/models/ir_model_access.py
Original file line number Diff line number Diff line change
@@ -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()
37 changes: 37 additions & 0 deletions base_user_role_extended/models/res_users.py
Original file line number Diff line number Diff line change
@@ -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()
Loading