diff --git a/auth_user_role/README.rst b/auth_user_role/README.rst new file mode 100644 index 0000000000..f8c875e982 --- /dev/null +++ b/auth_user_role/README.rst @@ -0,0 +1,124 @@ +======================== +360 ERP - Auth User Role +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:6b4bcdd74e55d4277803a74f7ac68e4bc51c14d2c4dec736cdfd1dea0ec9ee20 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/18.0/auth_user_role + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-18-0/server-auth-18-0-auth_user_role + :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-auth&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a generic engine to map Identity Provider (IdP) +attributes to Odoo user roles. It acts as an abstraction layer built on +top of the ``base_user_role`` module. + +By itself, this module does not handle authentication. Instead, it is +designed to be triggered by specialized "glue" modules (e.g., SAML, +OAuth, LDAP) during the login process. It evaluates incoming identity +payloads against a set of configured global rules and dynamically +provisions or revokes user roles. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure role mappings: + +1. Navigate to **Settings > Users & Companies > Identity Role + Mappings**. +2. Create a new mapping rule. +3. Define the **Identity Attribute**: Enter the exact payload attribute + key provided by your IdP (e.g., ``department``, ``groups``, + ``eduPersonAffiliation``). +4. Select the **Operator**: + + - **equals**: The payload value must exactly match the defined value. + - **contains**: The payload value must contain the defined value + (useful for comma-separated lists or longer strings). + +5. Define the **Value** you expect to receive from the IdP. +6. Select the **Role** (from ``base_user_role``) that should be assigned + when the condition is met. + +Usage +===== + +There is no direct user interaction required for this module. Once +configured, the evaluation and assignment of roles happen automatically +in the background whenever an integrated authentication provider +triggers the ``evaluate_and_apply_auth_roles`` method during user +sign-in. + +All role grants, reactivations, and revocations are automatically logged +in the Odoo server logs for security auditing. + +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 +------- + +* 360 ERP + +Contributors +------------ + +- Andrea Stirpe + +Other credits +------------- + +The development of this module has been financially supported by: + +- 360 ERP + +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-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_user_role/__init__.py b/auth_user_role/__init__.py new file mode 100644 index 0000000000..51d43d5f62 --- /dev/null +++ b/auth_user_role/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from .hooks import post_init_hook diff --git a/auth_user_role/__manifest__.py b/auth_user_role/__manifest__.py new file mode 100644 index 0000000000..1119510222 --- /dev/null +++ b/auth_user_role/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "360 ERP - Auth User Role", + "version": "18.0.1.0.0", + "author": "360 ERP, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/server-auth", + "license": "AGPL-3", + "depends": [ + "base_user_role", + ], + "data": [ + "security/ir.model.access.csv", + "views/auth_user_role_mapping_views.xml", + ], + "post_init_hook": "post_init_hook", +} diff --git a/auth_user_role/hooks.py b/auth_user_role/hooks.py new file mode 100644 index 0000000000..72d3b0b213 --- /dev/null +++ b/auth_user_role/hooks.py @@ -0,0 +1,11 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +def post_init_hook(env): + """Set the default strict sync parameter upon module installation.""" + param_key = "auth_user_role.strict_sync" + + # Only set it to 'True' if it doesn't already exist in the database + if not env["ir.config_parameter"].sudo().get_param(param_key): + env["ir.config_parameter"].sudo().set_param(param_key, "True") diff --git a/auth_user_role/models/__init__.py b/auth_user_role/models/__init__.py new file mode 100644 index 0000000000..ccf9cceb7e --- /dev/null +++ b/auth_user_role/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import auth_user_role_mapping +from . import res_config_settings +from . import res_users diff --git a/auth_user_role/models/auth_user_role_mapping.py b/auth_user_role/models/auth_user_role_mapping.py new file mode 100644 index 0000000000..d5cf649fdc --- /dev/null +++ b/auth_user_role/models/auth_user_role_mapping.py @@ -0,0 +1,61 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, tools + + +class AuthUserRoleMapping(models.Model): + _name = "auth.user.role.mapping" + _description = "Identity Role Mapping" + _rec_name = "attribute" + _order = "attribute" + + attribute = fields.Char( + string="Identity Attribute", + help=( + "The payload attribute to check (e.g., department, " + "groups, eduPersonAffiliation)." + ), + required=True, + ) + operator = fields.Selection( + selection=[("equals", "equals"), ("contains", "contains")], + default="equals", + required=True, + help="The operator to check the attribute against the value.", + ) + value = fields.Char(help="The value to check the attribute against.", required=True) + role_id = fields.Many2one( + "res.users.role", + help="The Odoo role to assign.", + required=True, + ondelete="cascade", + ) + + @api.model + @tools.ormcache() + def _get_all_mappings_cached(self): + """Fetch all mappings and cache them as native dicts for fast evaluation.""" + mappings = self.sudo().search([]) + return [ + { + "attribute": m.attribute, + "operator": m.operator, + "value": m.value, + "role_id": m.role_id.id, + } + for m in mappings + ] + + @api.model_create_multi + def create(self, vals_list): + self.env.registry.clear_cache() + return super().create(vals_list) + + def write(self, vals): + self.env.registry.clear_cache() + return super().write(vals) + + def unlink(self): + self.env.registry.clear_cache() + return super().unlink() diff --git a/auth_user_role/models/res_config_settings.py b/auth_user_role/models/res_config_settings.py new file mode 100644 index 0000000000..f531ca9157 --- /dev/null +++ b/auth_user_role/models/res_config_settings.py @@ -0,0 +1,19 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + auth_user_role_strict_sync = fields.Boolean( + string="Strict Identity Role Synchronization", + config_parameter="auth_user_role.strict_sync", + default=True, + help=( + "If enabled globally, any Odoo roles manually assigned to a user will be " + "removed if they are not explicitly provided by the " + "Identity Provider payload." + ), + ) diff --git a/auth_user_role/models/res_users.py b/auth_user_role/models/res_users.py new file mode 100644 index 0000000000..846153d23a --- /dev/null +++ b/auth_user_role/models/res_users.py @@ -0,0 +1,107 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class ResUser(models.Model): + _inherit = "res.users" + + def _get_mapped_roles(self, identity_payload): + """Helper to evaluate the identity payload against role mappings.""" + roles_to_add = set() + if not identity_payload: + return roles_to_add + + cached_mappings = self.env["auth.user.role.mapping"]._get_all_mappings_cached() + + for mapping in cached_mappings: + if mapping["attribute"] not in identity_payload: + continue + + attribute_values = identity_payload.get(mapping["attribute"]) + if not isinstance(attribute_values, list): + attribute_values = [attribute_values] + + for attr_val in attribute_values: + attr_str = str(attr_val) + if mapping["operator"] == "equals" and attr_str == mapping["value"]: + roles_to_add.add(mapping["role_id"]) + elif mapping["operator"] == "contains" and mapping["value"] in attr_str: + roles_to_add.add(mapping["role_id"]) + + return roles_to_add + + def evaluate_and_apply_auth_roles(self, identity_payload, strict_sync=None): + """ + Abstraction layer to evaluate an identity payload against global mappings + and apply the resulting roles to the user. + """ + self.ensure_one() + + # Fall back to global system parameter if not explicitly overridden + if strict_sync is None: + strict_sync = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("auth_user_role.strict_sync", "True") + == "True" + ) + + roles_to_add = self._get_mapped_roles(identity_payload) + + existing_lines = self.role_line_ids + existing_role_ids = set(existing_lines.mapped("role_id").ids) + active_role_ids = set(self._get_enabled_roles().mapped("role_id").ids) + + commands = [] + roles_removed_log = [] + roles_added_log = [] + + if strict_sync: + roles_to_remove = existing_role_ids - roles_to_add + if roles_to_remove: + lines_to_remove = existing_lines.filtered( + lambda el: el.role_id.id in roles_to_remove + ) + for line in lines_to_remove: + commands.append((2, line.id, 0)) + roles_removed_log.append(line.role_id.name) + + for role_id in roles_to_add: + if role_id not in existing_role_ids: + commands.append((0, 0, {"role_id": role_id})) + role = self.env["res.users.role"].browse(role_id) + roles_added_log.append(role.name) + elif role_id not in active_role_ids: + line_to_activate = existing_lines.filtered( + lambda el, rid=role_id: el.role_id.id == rid + ) + if line_to_activate: + commands.append((1, line_to_activate[0].id, {"date_to": False})) + role = self.env["res.users.role"].browse(role_id) + roles_added_log.append(f"{role.name} (Reactivated)") + + if commands: + self.write({"role_line_ids": commands}) + if roles_removed_log: + _logger.info( + "Identity Sync - Removed roles from user %s: %s", + self.login, + ", ".join(roles_removed_log), + ) + if roles_added_log: + _logger.info( + "Identity Sync - Granted roles to user %s: %s", + self.login, + ", ".join(roles_added_log), + ) + + if strict_sync: + self.set_groups_from_roles(force=True) + + return list(roles_to_add) diff --git a/auth_user_role/pyproject.toml b/auth_user_role/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/auth_user_role/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/auth_user_role/readme/CONFIGURE.md b/auth_user_role/readme/CONFIGURE.md new file mode 100644 index 0000000000..2f08af83bb --- /dev/null +++ b/auth_user_role/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +To configure role mappings: + +1. Navigate to **Settings > Users & Companies > Identity Role Mappings**. +2. Create a new mapping rule. +3. Define the **Identity Attribute**: Enter the exact payload attribute key provided by your IdP (e.g., `department`, `groups`, `eduPersonAffiliation`). +4. Select the **Operator**: + * **equals**: The payload value must exactly match the defined value. + * **contains**: The payload value must contain the defined value (useful for comma-separated lists or longer strings). +5. Define the **Value** you expect to receive from the IdP. +6. Select the **Role** (from `base_user_role`) that should be assigned when the condition is met. \ No newline at end of file diff --git a/auth_user_role/readme/CONTRIBUTORS.md b/auth_user_role/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0453d2bd32 --- /dev/null +++ b/auth_user_role/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* Andrea Stirpe diff --git a/auth_user_role/readme/CREDITS.md b/auth_user_role/readme/CREDITS.md new file mode 100644 index 0000000000..a848fae531 --- /dev/null +++ b/auth_user_role/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +* 360 ERP diff --git a/auth_user_role/readme/DESCRIPTION.md b/auth_user_role/readme/DESCRIPTION.md new file mode 100644 index 0000000000..4be750f928 --- /dev/null +++ b/auth_user_role/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This module provides a generic engine to map Identity Provider (IdP) attributes to Odoo user roles. +It acts as an abstraction layer built on top of the `base_user_role` module. + +By itself, this module does not handle authentication. +Instead, it is designed to be triggered by specialized "glue" modules (e.g., SAML, OAuth, LDAP) during the login process. +It evaluates incoming identity payloads against a set of configured global rules and dynamically provisions or revokes user roles. \ No newline at end of file diff --git a/auth_user_role/readme/USAGE.md b/auth_user_role/readme/USAGE.md new file mode 100644 index 0000000000..2c4ccd572c --- /dev/null +++ b/auth_user_role/readme/USAGE.md @@ -0,0 +1,4 @@ +There is no direct user interaction required for this module. +Once configured, the evaluation and assignment of roles happen automatically in the background whenever an integrated authentication provider triggers the `evaluate_and_apply_auth_roles` method during user sign-in. + +All role grants, reactivations, and revocations are automatically logged in the Odoo server logs for security auditing. \ No newline at end of file diff --git a/auth_user_role/security/ir.model.access.csv b/auth_user_role/security/ir.model.access.csv new file mode 100644 index 0000000000..fb627a3e95 --- /dev/null +++ b/auth_user_role/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_auth_user_role_mapping,auth.user.role.mapping,model_auth_user_role_mapping,base.group_system,1,1,1,1 diff --git a/auth_user_role/static/description/icon.png b/auth_user_role/static/description/icon.png new file mode 100644 index 0000000000..a78ac843dc Binary files /dev/null and b/auth_user_role/static/description/icon.png differ diff --git a/auth_user_role/static/description/index.html b/auth_user_role/static/description/index.html new file mode 100644 index 0000000000..66d3b1f9c3 --- /dev/null +++ b/auth_user_role/static/description/index.html @@ -0,0 +1,471 @@ + + + + + +360 ERP - Auth User Role + + + +
+

360 ERP - Auth User Role

+ + +

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

+

This module provides a generic engine to map Identity Provider (IdP) +attributes to Odoo user roles. It acts as an abstraction layer built on +top of the base_user_role module.

+

By itself, this module does not handle authentication. Instead, it is +designed to be triggered by specialized “glue” modules (e.g., SAML, +OAuth, LDAP) during the login process. It evaluates incoming identity +payloads against a set of configured global rules and dynamically +provisions or revokes user roles.

+

Table of contents

+ +
+

Configuration

+

To configure role mappings:

+
    +
  1. Navigate to Settings > Users & Companies > Identity Role +Mappings.
  2. +
  3. Create a new mapping rule.
  4. +
  5. Define the Identity Attribute: Enter the exact payload attribute +key provided by your IdP (e.g., department, groups, +eduPersonAffiliation).
  6. +
  7. Select the Operator:
      +
    • equals: The payload value must exactly match the defined value.
    • +
    • contains: The payload value must contain the defined value +(useful for comma-separated lists or longer strings).
    • +
    +
  8. +
  9. Define the Value you expect to receive from the IdP.
  10. +
  11. Select the Role (from base_user_role) that should be assigned +when the condition is met.
  12. +
+
+
+

Usage

+

There is no direct user interaction required for this module. Once +configured, the evaluation and assignment of roles happen automatically +in the background whenever an integrated authentication provider +triggers the evaluate_and_apply_auth_roles method during user +sign-in.

+

All role grants, reactivations, and revocations are automatically logged +in the Odoo server logs for security auditing.

+
+
+

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

+
    +
  • 360 ERP
  • +
+
+
+

Contributors

+
    +
  • Andrea Stirpe
  • +
+
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • 360 ERP
  • +
+
+
+

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

+

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

+
+
+
+ + diff --git a/auth_user_role/tests/__init__.py b/auth_user_role/tests/__init__.py new file mode 100644 index 0000000000..af8281cf3b --- /dev/null +++ b/auth_user_role/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_auth_user_roles +from . import test_hooks diff --git a/auth_user_role/tests/test_auth_user_roles.py b/auth_user_role/tests/test_auth_user_roles.py new file mode 100644 index 0000000000..113c84061b --- /dev/null +++ b/auth_user_role/tests/test_auth_user_roles.py @@ -0,0 +1,267 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import date, timedelta + +from odoo.tests.common import TransactionCase + + +class TestAuthUserRoles(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["ir.config_parameter"].set_param("auth_user_role.strict_sync", "False") + cls.test_role = cls.env["res.users.role"].create({"name": "Test Global Role"}) + cls.extra_manual_role = cls.env["res.users.role"].create( + {"name": "Extra Manual Role"} + ) + + cls.mapping = cls.env["auth.user.role.mapping"].create( + { + "attribute": "eduPersonAffiliation", + "operator": "equals", + "value": "role2", + "role_id": cls.test_role.id, + } + ) + + cls.user = cls.env["res.users"].create( + { + "name": "Test User", + "login": "user2@example.com", + } + ) + + def test_01_hook_evaluation_equals(self): + payload = {"mail": "user2@example.com", "eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + self.assertIn(self.test_role, self.user.role_line_ids.mapped("role_id")) + + def test_02_hook_evaluation_contains(self): + self.mapping.write({"operator": "contains", "value": "admin"}) + payload = { + "mail": "user2@example.com", + "eduPersonAffiliation": ["super_admin_user"], + } + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + self.assertIn(self.test_role, self.user.role_line_ids.mapped("role_id")) + + def test_03_signin_strict_sync_mode_removes_manual(self): + self.user.write( + {"role_line_ids": [(0, 0, {"role_id": self.extra_manual_role.id})]} + ) + payload = {"mail": "user2@example.com", "eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + assigned_roles = self.user.role_line_ids.mapped("role_id") + + self.assertNotIn(self.extra_manual_role, assigned_roles) + self.assertIn(self.test_role, assigned_roles) + + def test_04_missing_attribute(self): + payload = {"mail": "user2@example.com"} + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + self.assertNotIn(self.test_role.id, roles_added) + + def test_05_string_vs_list_attribute(self): + payload = {"mail": "user2@example.com", "eduPersonAffiliation": "role2"} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + self.assertIn(self.test_role, self.user.role_line_ids.mapped("role_id")) + + def test_06_multiple_mappings(self): + self.env["auth.user.role.mapping"].create( + { + "attribute": "department", + "operator": "equals", + "value": "IT", + "role_id": self.extra_manual_role.id, + } + ) + payload = { + "mail": "user2@example.com", + "eduPersonAffiliation": ["role2"], + "department": ["IT"], + } + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + + self.assertIn(self.test_role.id, roles_added) + self.assertIn(self.extra_manual_role.id, roles_added) + + def test_07_strict_sync_no_matches_removes_manual(self): + self.user.write( + {"role_line_ids": [(0, 0, {"role_id": self.extra_manual_role.id})]} + ) + payload = {"mail": "user2@example.com"} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + + self.assertEqual(len(self.user.role_line_ids), 0) + self.assertNotIn( + self.extra_manual_role, self.user.role_line_ids.mapped("role_id") + ) + + def test_08_case_sensitivity(self): + self.mapping.write({"operator": "equals", "value": "role2"}) + payload = {"mail": "user2@example.com", "eduPersonAffiliation": ["ROLE2"]} + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + self.assertNotIn(self.test_role.id, roles_added) + + def test_09_repeated_login_idempotency(self): + """Test that mapping application multiple times does not duplicate roles + or crash.""" + payload = {"eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + role_lines = self.user.role_line_ids.filtered( + lambda r: r.role_id == self.test_role + ) + self.assertEqual(len(role_lines), 1) + + def test_10_repeated_login_strict_sync(self): + """Test repeated evaluation when strict_sync is True.""" + payload = {"eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + role_lines = self.user.role_line_ids.filtered( + lambda r: r.role_id == self.test_role + ) + self.assertEqual(len(role_lines), 1) + + def test_11_multiple_values_same_attribute(self): + """Test when the payload returns a list of multiple values for + the same attribute.""" + role3 = self.env["res.users.role"].create({"name": "Role 3"}) + self.env["auth.user.role.mapping"].create( + { + "attribute": "eduPersonAffiliation", + "operator": "equals", + "value": "role3", + "role_id": role3.id, + } + ) + payload = { + "mail": "user2@example.com", + "eduPersonAffiliation": ["role2", "role3"], + } + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + + self.assertIn(self.test_role.id, roles_added) + self.assertIn(role3.id, roles_added) + + def test_12_reactivate_expired_role(self): + """Test that an expired role is reactivated instead of creating + a duplicate constraint error.""" + yesterday = date.today() - timedelta(days=1) + two_days_ago = date.today() - timedelta(days=2) + + self.user.write( + { + "role_line_ids": [ + ( + 0, + 0, + { + "role_id": self.test_role.id, + "date_from": two_days_ago, + "date_to": yesterday, + }, + ) + ] + } + ) + self.assertNotIn( + self.test_role.id, self.user._get_enabled_roles().mapped("role_id").ids + ) + + payload = {"eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=False) + + role_line = self.user.role_line_ids.filtered( + lambda r: r.role_id == self.test_role + ) + self.assertEqual(len(role_line), 1) + self.assertFalse(role_line.date_to) + self.assertEqual(role_line.date_from, two_days_ago) + self.assertIn( + self.test_role.id, self.user._get_enabled_roles().mapped("role_id").ids + ) + + def test_13_strict_sync_removes_native_groups(self): + """Test that strict sync removes manually assigned native Odoo groups.""" + native_group = self.env.ref("base.group_user") + self.user.write({"groups_id": [(4, native_group.id)]}) + self.assertIn(native_group, self.user.groups_id) + self.assertNotIn(native_group, self.test_role.implied_ids) + + payload = {"eduPersonAffiliation": ["role2"]} + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + + # The unrelated manual native group is stripped by set_groups_from_roles + self.assertNotIn(native_group, self.user.groups_id) + # The role-managed group should be present + self.assertIn(self.test_role.group_id, self.user.groups_id) + + def test_14_duplicate_role_mappings_deduplication(self): + """Test when multiple different mappings resolve to the EXACT SAME role.""" + self.env["auth.user.role.mapping"].create( + { + "attribute": "department", + "operator": "equals", + "value": "IT", + "role_id": self.test_role.id, + } + ) + payload = { + "mail": "user2@example.com", + "eduPersonAffiliation": ["role2"], + "department": ["IT"], + } + + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + self.assertEqual(roles_added.count(self.test_role.id), 1) + + role_lines = self.user.role_line_ids.filtered( + lambda r: r.role_id == self.test_role + ) + self.assertEqual(len(role_lines), 1) + + def test_15_empty_list_attribute(self): + """Test when the payload returns an empty list for a mapped attribute.""" + payload = {"mail": "user2@example.com", "eduPersonAffiliation": []} + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + self.assertNotIn(self.test_role.id, roles_added) + + def test_16_empty_identity_payload(self): + """Test when the payload is entirely empty.""" + payload = {} + roles_added = self.user.evaluate_and_apply_auth_roles( + payload, strict_sync=False + ) + self.assertNotIn(self.test_role.id, roles_added) + + def test_17_strict_sync_zero_roles_removes_all_groups(self): + """If a user drops to zero roles under strict sync, remove all groups.""" + native_group = self.env.ref("base.group_user") + self.user.write({"groups_id": [(4, native_group.id)]}) + + # Ensure they actually have it + self.assertIn(native_group, self.user.groups_id) + + # Send a payload that matches no roles with strict_sync=True + payload = {"mail": "user2@example.com"} # no mapped attributes + self.user.evaluate_and_apply_auth_roles(payload, strict_sync=True) + + # Assert roles dropped to 0 + self.assertEqual(len(self.user.role_line_ids), 0) + + # Assert ALL groups were wiped (including the manual native group) + self.assertEqual(len(self.user.groups_id), 0) diff --git a/auth_user_role/tests/test_hooks.py b/auth_user_role/tests/test_hooks.py new file mode 100644 index 0000000000..565975a98a --- /dev/null +++ b/auth_user_role/tests/test_hooks.py @@ -0,0 +1,38 @@ +# Copyright 2026 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import TransactionCase + +from odoo.addons.auth_user_role.hooks import post_init_hook + + +class TestHooks(TransactionCase): + def test_post_init_hook_creates_param(self): + """Test that the hook creates the parameter if it is missing.""" + param_key = "auth_user_role.strict_sync" + + # Ensure the parameter is completely removed + self.env["ir.config_parameter"].sudo().search( + [("key", "=", param_key)] + ).unlink() + + # Run the hook manually + post_init_hook(self.env) + + # Verify it was created and set to 'True' + val = self.env["ir.config_parameter"].sudo().get_param(param_key) + self.assertEqual(val, "True") + + def test_post_init_hook_respects_existing_param(self): + """Test that the hook does NOT overwrite an existing parameter.""" + param_key = "auth_user_role.strict_sync" + + # Explicitly set the parameter to 'False' before the hook runs + self.env["ir.config_parameter"].sudo().set_param(param_key, "False") + + # Run the hook manually + post_init_hook(self.env) + + # Verify the hook respected the existing 'False' value + val = self.env["ir.config_parameter"].sudo().get_param(param_key) + self.assertEqual(val, "False") diff --git a/auth_user_role/views/auth_user_role_mapping_views.xml b/auth_user_role/views/auth_user_role_mapping_views.xml new file mode 100644 index 0000000000..05e493de42 --- /dev/null +++ b/auth_user_role/views/auth_user_role_mapping_views.xml @@ -0,0 +1,37 @@ + + + + auth.user.role.mapping.tree + auth.user.role.mapping + + + + + + + + + + + + Identity Role Mappings + auth.user.role.mapping + list,form + +

+ Create your first identity role mapping! +

+

+ Map incoming attributes from your Identity Providers (SAML, OAuth, etc.) to Odoo roles. +

+
+
+ + +