diff --git a/base_user_role_archive/README.rst b/base_user_role_archive/README.rst new file mode 100644 index 0000000..13920f3 --- /dev/null +++ b/base_user_role_archive/README.rst @@ -0,0 +1,106 @@ +====================== +Base User Role Archive +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:575bd928aa37b05539c97798d758feb381e21c2df6b42319e541d1a7f288316c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/18.0/base_user_role_archive + :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_archive + :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 ``base_user_role_extended`` to add **archive** and +**unarchive** access control to role-based model permissions. + +It adds two new boolean fields to ``ir.model.access``: + +- **Archive Access** (``perm_archive``): controls whether a user may + archive records of that model. +- **Unarchive Access** (``perm_unarchive``): controls whether a user may + unarchive records of that model. + +When a role's model access record has these permissions unchecked, the +Archive / Unarchive actions are automatically hidden in the UI (List and +Form view action menus) and blocked at the server level whenever the +user tries to write ``active = False/True`` on a record. + +The enforcement logic follows the same strict role-group pattern as +``base_user_role_extended``: for users with active roles, only the +role's associated group access records are evaluated. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Install the module. +2. Go to **Settings → Users & Companies → Roles** and open a role. +3. In the **Model Access** tab, find the model you want to restrict. +4. Uncheck **Archive Access** to prevent archiving, or **Unarchive + Access** to prevent unarchiving. +5. Save the role. The Archive / Unarchive options will disappear from + the action menus for users with this role, and server-side + enforcement will block any direct write to the ``active`` field as + well. + +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 ``\ \_ + +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_archive/__init__.py b/base_user_role_archive/__init__.py new file mode 100644 index 0000000..91e47f4 --- /dev/null +++ b/base_user_role_archive/__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_user_role_archive/__manifest__.py b/base_user_role_archive/__manifest__.py new file mode 100644 index 0000000..ed12b0c --- /dev/null +++ b/base_user_role_archive/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Base User Role Archive", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "Extends user roles for archive access in model access", + "author": "CIT Services, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-backend", + "license": "AGPL-3", + "depends": ["base_user_role_extended", "base_archive_manager"], + "installable": True, +} diff --git a/base_user_role_archive/models/__init__.py b/base_user_role_archive/models/__init__.py new file mode 100644 index 0000000..85e3cb2 --- /dev/null +++ b/base_user_role_archive/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 ir_model_access +from . import res_users_role diff --git a/base_user_role_archive/models/ir_model_access.py b/base_user_role_archive/models/ir_model_access.py new file mode 100644 index 0000000..e7b5b69 --- /dev/null +++ b/base_user_role_archive/models/ir_model_access.py @@ -0,0 +1,25 @@ +# Copyright 2026 CIT Services +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from odoo import api, models, tools + + +class IrModelAccess(models.Model): + _inherit = "ir.model.access" + + @api.model + @tools.ormcache("self.env.uid", "model_name") + def _check_archive_access_cached(self, model_name): + """ + Override to enforce exclusive role-based model access. + """ + self = self.with_context(role=True) + return super()._check_archive_access_cached(model_name) + + @api.model + @tools.ormcache("self.env.uid", "model_name") + def _check_unarchive_access_cached(self, model_name): + """ + Override to enforce exclusive role-based model access. + """ + self = self.with_context(role=True) + return super()._check_unarchive_access_cached(model_name) diff --git a/base_user_role_archive/models/res_users_role.py b/base_user_role_archive/models/res_users_role.py new file mode 100644 index 0000000..6685980 --- /dev/null +++ b/base_user_role_archive/models/res_users_role.py @@ -0,0 +1,18 @@ +# Copyright 2026 CIT-Services +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ResUsersRole(models.Model): + _inherit = "res.users.role" + + def collect_all_perm_fields(self, perm_fields=None): + res = super().collect_all_perm_fields(perm_fields) + res.update( + { + "perm_archive": False, + "perm_unarchive": False, + } + ) + return res diff --git a/base_user_role_archive/pyproject.toml b/base_user_role_archive/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/base_user_role_archive/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_user_role_archive/readme/CONTRIBUTORS.md b/base_user_role_archive/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..59b132f --- /dev/null +++ b/base_user_role_archive/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [CIT-Services](cit-services.eu) + - Solomon Prabu diff --git a/base_user_role_archive/readme/DESCRIPTION.md b/base_user_role_archive/readme/DESCRIPTION.md new file mode 100644 index 0000000..6d691f0 --- /dev/null +++ b/base_user_role_archive/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +This module extends `base_user_role_extended` to add **archive** and +**unarchive** access control to role-based model permissions. + +It adds two new boolean fields to `ir.model.access`: + +- **Archive Access** (`perm_archive`): controls whether a user may archive + records of that model. +- **Unarchive Access** (`perm_unarchive`): controls whether a user may + unarchive records of that model. + +When a role's model access record has these permissions unchecked, the +Archive / Unarchive actions are automatically hidden in the UI (List and +Form view action menus) and blocked at the server level whenever the +user tries to write `active = False/True` on a record. + +The enforcement logic follows the same strict role-group pattern as +`base_user_role_extended`: for users with active roles, only the role's +associated group access records are evaluated. diff --git a/base_user_role_archive/readme/USAGE.md b/base_user_role_archive/readme/USAGE.md new file mode 100644 index 0000000..cb0b7dc --- /dev/null +++ b/base_user_role_archive/readme/USAGE.md @@ -0,0 +1,8 @@ +1. Install the module. +2. Go to **Settings → Users & Companies → Roles** and open a role. +3. In the **Model Access** tab, find the model you want to restrict. +4. Uncheck **Archive Access** to prevent archiving, or **Unarchive Access** + to prevent unarchiving. +5. Save the role. The Archive / Unarchive options will disappear from the + action menus for users with this role, and server-side enforcement will + block any direct write to the `active` field as well. diff --git a/base_user_role_archive/static/description/index.html b/base_user_role_archive/static/description/index.html new file mode 100644 index 0000000..8819995 --- /dev/null +++ b/base_user_role_archive/static/description/index.html @@ -0,0 +1,453 @@ + + + + + +Base User Role Archive + + + +
+

Base User Role Archive

+ + +

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

+

This module extends base_user_role_extended to add archive and +unarchive access control to role-based model permissions.

+

It adds two new boolean fields to ir.model.access:

+
    +
  • Archive Access (perm_archive): controls whether a user may +archive records of that model.
  • +
  • Unarchive Access (perm_unarchive): controls whether a user may +unarchive records of that model.
  • +
+

When a role’s model access record has these permissions unchecked, the +Archive / Unarchive actions are automatically hidden in the UI (List and +Form view action menus) and blocked at the server level whenever the +user tries to write active = False/True on a record.

+

The enforcement logic follows the same strict role-group pattern as +base_user_role_extended: for users with active roles, only the +role’s associated group access records are evaluated.

+

Table of contents

+ +
+

Usage

+
    +
  1. Install the module.
  2. +
  3. Go to Settings → Users & Companies → Roles and open a role.
  4. +
  5. In the Model Access tab, find the model you want to restrict.
  6. +
  7. Uncheck Archive Access to prevent archiving, or Unarchive +Access to prevent unarchiving.
  8. +
  9. Save the role. The Archive / Unarchive options will disappear from +the action menus for users with this role, and server-side +enforcement will block any direct write to the active field as +well.
  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

+
    +
  • CIT Services <https://www.cit-services.eu>_
  • +
+
+
+

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_archive/tests/__init__.py b/base_user_role_archive/tests/__init__.py new file mode 100644 index 0000000..b7753c6 --- /dev/null +++ b/base_user_role_archive/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_user_role_archive diff --git a/base_user_role_archive/tests/test_base_user_role_archive.py b/base_user_role_archive/tests/test_base_user_role_archive.py new file mode 100644 index 0000000..ec0e0c0 --- /dev/null +++ b/base_user_role_archive/tests/test_base_user_role_archive.py @@ -0,0 +1,190 @@ +# Copyright 2026 CIT Services +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, new_test_user + + +class TestBaseUserRoleArchive(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"), + ("res_users_settings", "calendar_default_privacy"), + ] + 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.test_user = cls.env.ref( + "base.user_demo", raise_if_not_found=False + ) or new_test_user( + cls.env, + login="test_archive_user", + name="Test Archive User", + groups="base.group_user", + ) + cls.model_res_partner = cls.env.ref("base.model_res_partner") + + # Create a group that grants archive and unarchive access + cls.group_archive_manager = cls.env["res.groups"].create( + {"name": "Archive Manager"} + ) + cls.env["ir.model.access"].create( + { + "name": "partner archive manager", + "model_id": cls.model_res_partner.id, + "group_id": cls.group_archive_manager.id, + "perm_read": True, + "perm_write": True, + "perm_create": False, + "perm_unlink": False, + "perm_archive": True, + "perm_unarchive": True, + } + ) + + # Create a group that grants read and write, but NO archive access + cls.group_no_archive = cls.env["res.groups"].create( + {"name": "No Archive Partner"} + ) + cls.env["ir.model.access"].create( + { + "name": "partner no archive", + "model_id": cls.model_res_partner.id, + "group_id": cls.group_no_archive.id, + "perm_read": True, + "perm_write": True, + "perm_create": False, + "perm_unlink": False, + "perm_archive": False, + "perm_unarchive": False, + } + ) + + def setUp(self): + super().setUp() + self.test_user.role_line_ids.unlink() + self.env.registry.clear_cache() + + def test_collect_all_perm_fields(self): + """Test that collect_all_perm_fields includes archive permissions.""" + role = self.env["res.users.role"].new() + perm_fields = role.collect_all_perm_fields() + + self.assertIn("perm_archive", perm_fields) + self.assertIn("perm_unarchive", perm_fields) + self.assertFalse(perm_fields["perm_archive"]) + self.assertFalse(perm_fields["perm_unarchive"]) + + def test_archive_access_with_role(self): + """Test archive access is granted when user's role has the required group.""" + role = self.env["res.users.role"].create( + { + "name": "Archive Role", + "implied_ids": [(4, self.group_archive_manager.id)], + } + ) + self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role.id, + } + ) + self.env.registry.clear_cache() + + access_model = self.env["ir.model.access"].with_user(self.test_user) + can_archive = access_model._check_archive_access( + "res.partner", raise_exception=False + ) + can_unarchive = access_model._check_unarchive_access( + "res.partner", raise_exception=False + ) + self.assertTrue(can_archive) + self.assertTrue(can_unarchive) + + def test_archive_access_revoked(self): + """Test archive access is denied when group is removed from role.""" + role = self.env["res.users.role"].create( + { + "name": "No Archive Role", + "implied_ids": [(4, self.group_no_archive.id)], + } + ) + self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role.id, + } + ) + self.env.registry.clear_cache() + + access_model = self.env["ir.model.access"].with_user(self.test_user) + can_archive = access_model._check_archive_access( + "res.partner", raise_exception=False + ) + can_unarchive = access_model._check_unarchive_access( + "res.partner", raise_exception=False + ) + self.assertFalse(can_archive) + self.assertFalse(can_unarchive) + + def test_check_archive_access_cached_override(self): + """Test _check_archive_access_cached and _check_unarchive_access_cached + from ir_model_access.py enforce role-based context.""" + # 1. Granted via Archive Role + role_archive = self.env["res.users.role"].create( + { + "name": "Cached Archive Role", + "implied_ids": [(4, self.group_archive_manager.id)], + } + ) + role_line = self.env["res.users.role.line"].create( + { + "user_id": self.test_user.id, + "role_id": role_archive.id, + } + ) + self.env.registry.clear_cache() + + access_model = self.env["ir.model.access"].with_user(self.test_user) + self.assertTrue(bool(access_model._check_archive_access_cached("res.partner"))) + self.assertTrue( + bool(access_model._check_unarchive_access_cached("res.partner")) + ) + self.assertTrue(access_model._check_archive_access("res.partner")) + self.assertTrue(access_model._check_unarchive_access("res.partner")) + + # 2. Denied via No Archive Role + role_no_archive = self.env["res.users.role"].create( + { + "name": "Cached No Archive Role", + "implied_ids": [(4, self.group_no_archive.id)], + } + ) + role_line.role_id = role_no_archive.id + self.env.registry.clear_cache() + + access_model = self.env["ir.model.access"].with_user(self.test_user) + self.assertFalse(bool(access_model._check_archive_access_cached("res.partner"))) + self.assertFalse( + bool(access_model._check_unarchive_access_cached("res.partner")) + ) + + with self.assertRaises(AccessError): + access_model._check_archive_access("res.partner", raise_exception=True) + with self.assertRaises(AccessError): + access_model._check_unarchive_access("res.partner", raise_exception=True)