diff --git a/base_action_manager/README.rst b/base_action_manager/README.rst new file mode 100644 index 0000000..ea9962e --- /dev/null +++ b/base_action_manager/README.rst @@ -0,0 +1,120 @@ +=================== +Base Action Manager +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b392fc8b94529d291e28618af43e47e1d5b7ae931944a6ec265d4a5074db91b3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--ux-lightgray.png?logo=github + :target: https://github.com/OCA/server-ux/tree/18.0/base_action_manager + :alt: OCA/server-ux +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-ux-18-0/server-ux-18-0-base_action_manager + :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-ux&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides granular visibility control over Server and Window +Actions based on User Groups: + +1. It introduces the ability to configure "Restricted Server Actions" + and "Restricted Window Actions" directly within Odoo Security Groups. +2. It enforces a "Restricted / Deny List" policy, ensuring that users + cannot view or execute actions that are restricted for any of their + assigned groups (either directly or transitively inherited). + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure action restrictions: + +1. Go to **Settings > Users & Companies > Groups** and select the Group + you want to configure. +2. Under the **Restricted Window Actions** tab, select the window + actions you want to hide from members of this group. +3. Under the **Restricted Server Actions** tab, select the server + actions you want to hide from members of this group. + +Note: Group privilege inheritance is transitive. If an action is +restricted for a group, that restriction propagates to all users who +belong to the group, including those who inherit the group (e.g., +restricting a lower-privilege group like "User" will also restrict a +higher-privilege group like "Manager" that implies/inherits it). + +Usage +===== + +Once action restrictions are configured for user groups: + +1. Assign the appropriate groups to the user. +2. The user navigates to the target model view (e.g., Partners). +3. Under the **Action** gear icon menu, any window or server action that + is restricted for the user's groups will be hidden. All other actions + remain visible. + +Known issues / Roadmap +====================== + +- None. + +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 `__ + + - Prayag + +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-ux `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_action_manager/__init__.py b/base_action_manager/__init__.py new file mode 100644 index 0000000..4f12309 --- /dev/null +++ b/base_action_manager/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models diff --git a/base_action_manager/__manifest__.py b/base_action_manager/__manifest__.py new file mode 100644 index 0000000..d1387dc --- /dev/null +++ b/base_action_manager/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Base Action Manager", + "summary": "Manage window and server actions visibility per user group", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "CIT Services,, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-ux", + "license": "AGPL-3", + "installable": True, + "depends": ["base"], + "data": [ + "views/res_groups_views.xml", + ], +} diff --git a/base_action_manager/models/__init__.py b/base_action_manager/models/__init__.py new file mode 100644 index 0000000..24730ce --- /dev/null +++ b/base_action_manager/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import res_groups +from . import ir_actions_actions diff --git a/base_action_manager/models/ir_actions_actions.py b/base_action_manager/models/ir_actions_actions.py new file mode 100644 index 0000000..6b6f32c --- /dev/null +++ b/base_action_manager/models/ir_actions_actions.py @@ -0,0 +1,76 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, models +from odoo.exceptions import AccessError + + +class IrActionsActions(models.Model): + _inherit = "ir.actions.actions" + + def _is_action_action_restricted(self): + """Returns True if the current action is restricted for the current user, + taking group privilege inheritance into account. + """ + self.ensure_one() + if self.env.is_superuser(): + return False + + action_sudo = self.sudo() + action_type = action_sudo.type + user_groups = self.env.user.sudo().groups_id + if action_type == "ir.actions.act_window": + return action_sudo.id in user_groups.restricted_window_action_ids.ids + if action_type == "ir.actions.server": + return action_sudo.id in user_groups.restricted_server_action_ids.ids + + return ( + action_sudo.id in user_groups.restricted_window_action_ids.ids + or action_sudo.id in user_groups.restricted_server_action_ids.ids + ) + + def _check_action_action_restrictions(self): + """Raises AccessError if any action in self is restricted.""" + for action in self: + if action._is_action_action_restricted(): + raise AccessError( + _( + "You are not allowed to access this action because it " + "is restricted for one of your user groups." + ) + ) + + def _get_action_dict(self): + """Verify restrictions before loading action details for execution.""" + self._check_action_action_restrictions() + return super()._get_action_dict() + + @api.model + def get_bindings(self, model_name): + """Filter out restricted actions from sidebars and bindings.""" + result = super().get_bindings(model_name) + if self.env.is_superuser(): + return result + + for key in ("action", "report"): + if key not in result: + continue + action_ids = [act.get("id") for act in result[key] if act.get("id")] + if not action_ids: + continue + + actions = self.browse(action_ids) + restricted_ids = { + action.id for action in actions if action._is_action_action_restricted() + } + + if restricted_ids: + filtered_list = [ + act for act in result[key] if act.get("id") not in restricted_ids + ] + if filtered_list: + result[key] = filtered_list + else: + result.pop(key) + + return result diff --git a/base_action_manager/models/res_groups.py b/base_action_manager/models/res_groups.py new file mode 100644 index 0000000..de4e07c --- /dev/null +++ b/base_action_manager/models/res_groups.py @@ -0,0 +1,25 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResGroups(models.Model): + _inherit = "res.groups" + + restricted_window_action_ids = fields.Many2many( + comodel_name="ir.actions.act_window", + relation="res_groups_restricted_window_action_rel", + column1="gid", + column2="act_id", + string="Restricted Window Actions", + help="Window actions restricted for members of this group.", + ) + restricted_server_action_ids = fields.Many2many( + comodel_name="ir.actions.server", + relation="res_groups_restricted_server_action_rel", + column1="gid", + column2="act_id", + string="Restricted Server Actions", + help="Server actions restricted for members of this group.", + ) diff --git a/base_action_manager/pyproject.toml b/base_action_manager/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/base_action_manager/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_action_manager/readme/CONFIGURE.md b/base_action_manager/readme/CONFIGURE.md new file mode 100644 index 0000000..0ade31c --- /dev/null +++ b/base_action_manager/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +To configure action restrictions: + +1. Go to **Settings > Users & Companies > Groups** and select the Group you want to configure. +2. Under the **Restricted Window Actions** tab, select the window actions you want to hide from members of this group. +3. Under the **Restricted Server Actions** tab, select the server actions you want to hide from members of this group. + +Note: Group privilege inheritance is transitive. If an action is restricted for a group, that restriction propagates to all users who belong to the group, including those who inherit the group (e.g., restricting a lower-privilege group like "User" will also restrict a higher-privilege group like "Manager" that implies/inherits it). + diff --git a/base_action_manager/readme/CONTRIBUTORS.md b/base_action_manager/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..4b54f31 --- /dev/null +++ b/base_action_manager/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [CIT-Services](cit-services.eu) + - Prayag \<\> diff --git a/base_action_manager/readme/DESCRIPTION.md b/base_action_manager/readme/DESCRIPTION.md new file mode 100644 index 0000000..abd79ff --- /dev/null +++ b/base_action_manager/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module provides granular visibility control over Server and Window Actions based on User Groups: + +1. It introduces the ability to configure "Restricted Server Actions" and "Restricted Window Actions" directly within Odoo Security Groups. +2. It enforces a "Restricted / Deny List" policy, ensuring that users cannot view or execute actions that are restricted for any of their assigned groups (either directly or transitively inherited). + diff --git a/base_action_manager/readme/ROADMAP.md b/base_action_manager/readme/ROADMAP.md new file mode 100644 index 0000000..1ac5b36 --- /dev/null +++ b/base_action_manager/readme/ROADMAP.md @@ -0,0 +1 @@ +- None. diff --git a/base_action_manager/readme/USAGE.md b/base_action_manager/readme/USAGE.md new file mode 100644 index 0000000..eae432e --- /dev/null +++ b/base_action_manager/readme/USAGE.md @@ -0,0 +1,5 @@ +Once action restrictions are configured for user groups: + +1. Assign the appropriate groups to the user. +2. The user navigates to the target model view (e.g., Partners). +3. Under the **Action** gear icon menu, any window or server action that is restricted for the user's groups will be hidden. All other actions remain visible. diff --git a/base_action_manager/static/description/index.html b/base_action_manager/static/description/index.html new file mode 100644 index 0000000..0fda548 --- /dev/null +++ b/base_action_manager/static/description/index.html @@ -0,0 +1,472 @@ + + + + + +Base Action Manager + + + +
+

Base Action Manager

+ + +

Beta License: AGPL-3 OCA/server-ux Translate me on Weblate Try me on Runboat

+

This module provides granular visibility control over Server and Window +Actions based on User Groups:

+
    +
  1. It introduces the ability to configure “Restricted Server Actions” +and “Restricted Window Actions” directly within Odoo Security Groups.
  2. +
  3. It enforces a “Restricted / Deny List” policy, ensuring that users +cannot view or execute actions that are restricted for any of their +assigned groups (either directly or transitively inherited).
  4. +
+

Table of contents

+ +
+

Configuration

+

To configure action restrictions:

+
    +
  1. Go to Settings > Users & Companies > Groups and select the Group +you want to configure.
  2. +
  3. Under the Restricted Window Actions tab, select the window +actions you want to hide from members of this group.
  4. +
  5. Under the Restricted Server Actions tab, select the server +actions you want to hide from members of this group.
  6. +
+

Note: Group privilege inheritance is transitive. If an action is +restricted for a group, that restriction propagates to all users who +belong to the group, including those who inherit the group (e.g., +restricting a lower-privilege group like “User” will also restrict a +higher-privilege group like “Manager” that implies/inherits it).

+
+
+

Usage

+

Once action restrictions are configured for user groups:

+
    +
  1. Assign the appropriate groups to the user.
  2. +
  3. The user navigates to the target model view (e.g., Partners).
  4. +
  5. Under the Action gear icon menu, any window or server action that +is restricted for the user’s groups will be hidden. All other actions +remain visible.
  6. +
+
+
+

Known issues / Roadmap

+
    +
  • None.
  • +
+
+
+

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
  • +
  • +
+
+ +
+

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-ux project on GitHub.

+

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

+
+
+
+ + diff --git a/base_action_manager/tests/__init__.py b/base_action_manager/tests/__init__.py new file mode 100644 index 0000000..8ecc95b --- /dev/null +++ b/base_action_manager/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_base_action diff --git a/base_action_manager/tests/test_base_action.py b/base_action_manager/tests/test_base_action.py new file mode 100644 index 0000000..732d0b1 --- /dev/null +++ b/base_action_manager/tests/test_base_action.py @@ -0,0 +1,204 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase + + +class TestBaseAction(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + polluted_columns = [ + ("res_partner", "autopost_bills"), + ("res_users", "notification_type"), + ] + for table, column in polluted_columns: + cls.env.cr.execute(f""" + SELECT column_name + FROM information_schema.columns + WHERE table_name='{table}' AND column_name='{column}' + """) + if cls.env.cr.fetchone(): + cls.env.cr.execute( + f"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL" + ) + + cls.group_user = cls.env.ref("base.group_user") + + cls.child_group = cls.env["res.groups"].create( + { + "name": "User: Own Documents Only", + "implied_ids": [Command.link(cls.group_user.id)], + } + ) + cls.parent_group = cls.env["res.groups"].create( + { + "name": "User: All Documents", + "implied_ids": [Command.link(cls.child_group.id)], + } + ) + + cls.unrelated_group = cls.env["res.groups"].create( + { + "name": "Project / User", + "implied_ids": [Command.link(cls.group_user.id)], + } + ) + + cls.test_user = cls.env["res.users"].create( + { + "name": "Test Action User", + "login": "test_action_user", + "groups_id": [Command.set([cls.env.ref("base.group_system").id])], + } + ) + + cls.model_res_partner = cls.env["ir.model"].search( + [("model", "=", "res.partner")], limit=1 + ) + + cls.window_action = cls.env["ir.actions.act_window"].create( + { + "name": "Test Window Action", + "res_model": "res.partner", + "binding_model_id": cls.model_res_partner.id, + } + ) + + def test_restricted_action_inheritance(self): + """Test that restricting an action hides it from bindings.""" + self.child_group.write( + {"restricted_window_action_ids": [Command.link(self.window_action.id)]} + ) + self.test_user.write( + { + "groups_id": [ + Command.set( + [self.parent_group.id, self.env.ref("base.group_system").id] + ) + ] + } + ) + with self.assertRaises(AccessError): + self.window_action.with_user(self.test_user)._get_action_dict() + + bindings = ( + self.env["ir.actions.actions"] + .with_user(self.test_user) + .get_bindings("res.partner") + ) + action_bindings = [a["id"] for a in bindings.get("action", [])] + self.assertNotIn(self.window_action.id, action_bindings) + + self.child_group.write( + {"restricted_window_action_ids": [Command.unlink(self.window_action.id)]} + ) + self.parent_group.write( + {"restricted_window_action_ids": [Command.link(self.window_action.id)]} + ) + with self.assertRaises(AccessError): + self.window_action.with_user(self.test_user)._get_action_dict() + + bindings = ( + self.env["ir.actions.actions"] + .with_user(self.test_user) + .get_bindings("res.partner") + ) + action_bindings = [a["id"] for a in bindings.get("action", [])] + self.assertNotIn(self.window_action.id, action_bindings) + + self.test_user.write( + { + "groups_id": [ + Command.set( + [ + self.child_group.id, + self.unrelated_group.id, + self.env.ref("base.group_system").id, + ] + ) + ] + } + ) + + self.window_action.with_user(self.test_user)._get_action_dict() + + bindings = ( + self.env["ir.actions.actions"] + .with_user(self.test_user) + .get_bindings("res.partner") + ) + action_bindings = [a["id"] for a in bindings.get("action", [])] + self.assertIn(self.window_action.id, action_bindings) + + def test_superuser_bypass(self): + """Superuser bypasses restrictions entirely.""" + self.child_group.write( + {"restricted_window_action_ids": [Command.link(self.window_action.id)]} + ) + root_user = self.env.ref("base.user_root") or self.env.user.browse(1) + self.window_action.with_user(root_user)._check_action_action_restrictions() + self.assertFalse( + self.window_action.with_user(root_user)._is_action_action_restricted() + ) + bindings = ( + self.env["ir.actions.actions"] + .with_user(root_user) + .get_bindings("res.partner") + ) + action_bindings = [a["id"] for a in bindings.get("action", [])] + self.assertIn(self.window_action.id, action_bindings) + + def test_no_group_overlap(self): + """User has no group overlap with the restricted group.""" + self.child_group.write( + {"restricted_window_action_ids": [Command.link(self.window_action.id)]} + ) + self.test_user.write( + { + "groups_id": [ + Command.set( + [ + self.unrelated_group.id, + self.env.ref("base.group_system").id, + ] + ) + ] + } + ) + self.assertFalse( + self.window_action.with_user(self.test_user)._is_action_action_restricted() + ) + + def test_get_bindings_filtering(self): + """Test that get_bindings filters out restricted actions.""" + self.child_group.write( + {"restricted_window_action_ids": [Command.link(self.window_action.id)]} + ) + self.test_user.write( + { + "groups_id": [ + Command.set( + [self.child_group.id, self.env.ref("base.group_system").id] + ) + ] + } + ) + + import unittest.mock as mock + + mock_result = {"action": [{"id": self.window_action.id, "name": "Test Action"}]} + with mock.patch( + "odoo.addons.base.models.ir_actions.IrActions.get_bindings", + return_value=mock_result, + ): + bindings = ( + self.env["ir.actions.actions"] + .with_user(self.test_user) + .get_bindings("res.partner") + ) + self.assertNotIn("action", bindings) diff --git a/base_action_manager/views/res_groups_views.xml b/base_action_manager/views/res_groups_views.xml new file mode 100644 index 0000000..1b79bc0 --- /dev/null +++ b/base_action_manager/views/res_groups_views.xml @@ -0,0 +1,45 @@ + + + + + res.groups.form.inherit + res.groups + + + + + + + + + + + + + + + + + + + + + + + + +