From 8806aa4fd17543420bba6086a1d59e36a839d523 Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sat, 24 Oct 2026 09:35:54 +0700 Subject: [PATCH 1/3] [IMP] impersonate_login: impersonation mode per user Each user gets an Impersonation setting (administrators only, Access Rights tab of the user form): - Default: follow the general setting (Restrict Impersonation of 'Administration: Settings' Users); - Allowed: the user can always be impersonated, even with the 'Administration: Settings' rights; - Forbidden: the user can never be impersonated. The check is skipped when going back to the original user. Signed-off-by: Claude Perrin --- impersonate_login/README.rst | 14 +++-- impersonate_login/models/res_users.py | 54 ++++++++++++++----- impersonate_login/readme/CONFIGURE.md | 8 +++ .../static/description/index.html | 36 +++++++------ .../tests/test_impersonate_login.py | 47 ++++++++++++++++ impersonate_login/views/res_users.xml | 15 ++++++ 6 files changed, 138 insertions(+), 36 deletions(-) diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst index 658ce207ec..2a86fd4271 100644 --- a/impersonate_login/README.rst +++ b/impersonate_login/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Impersonate Login ================= @@ -17,7 +13,7 @@ Impersonate Login .. |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/license-AGPL--3-blue.png +.. |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 @@ -64,6 +60,14 @@ If you want to prevent impersonation of users with the *Administration: Settings* rights, enable the *Restrict Impersonation of "Administration: Settings" Users* option in the settings. +Each user also has an *Impersonation* setting, on the *Access Rights* +tab of the user form (administrators only): + +- *Default*: follow the general setting above; +- *Allowed*: the user can always be impersonated, even with the + *Administration: Settings* rights; +- *Forbidden*: the user can never be impersonated. + Usage ===== diff --git a/impersonate_login/models/res_users.py b/impersonate_login/models/res_users.py index 0703234d87..82501dda8d 100644 --- a/impersonate_login/models/res_users.py +++ b/impersonate_login/models/res_users.py @@ -15,6 +15,44 @@ class Users(models.Model): _inherit = "res.users" + impersonate_mode = fields.Selection( + selection=[ + ("default", "Default"), + ("allowed", "Allowed"), + ("forbidden", "Forbidden"), + ], + string="Impersonation", + default="default", + required=True, + groups="base.group_system", + help="Default: follow the general setting (when 'Restrict Impersonation of " + "Administration: Settings Users' is enabled, users with that access right " + "cannot be impersonated).\n" + "Allowed: this user can always be impersonated, even with that access right.\n" + "Forbidden: this user can never be impersonated.", + ) + + def _check_can_be_impersonated(self): + """Raise if nobody may impersonate this user.""" + self.ensure_one() + mode = self.sudo().impersonate_mode + if mode == "forbidden": + raise UserError(self.env._("This user cannot be impersonated.")) + if mode == "allowed": + return + config_restrict = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("impersonate_login.restrict_impersonate_admin_settings") + ) + if config_restrict and self.env.ref("base.group_system") in self.groups_id: + raise UserError( + self.env._( + "You cannot impersonate users with" + " 'Administration: Settings' access rights." + ) + ) + def _get_partner_name(self, user_id): return self.env["res.users"].browse(user_id).partner_id.name @@ -24,20 +62,8 @@ def _is_impersonate_user(self): def impersonate_login(self): if request: - config_restrict = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("impersonate_login.restrict_impersonate_admin_settings") - ) - if config_restrict: - admin_settings_group = self.env.ref("base.group_system") - if admin_settings_group in self.groups_id: - raise UserError( - self.env._( - "You cannot impersonate users with" - " 'Administration: Settings' access rights." - ) - ) + if self.id != request.session.impersonate_from_uid: + self._check_can_be_impersonated() if request.session.impersonate_from_uid: if self.id == request.session.impersonate_from_uid: return self.back_to_origin_login() diff --git a/impersonate_login/readme/CONFIGURE.md b/impersonate_login/readme/CONFIGURE.md index 649fb31c76..2d9adfa302 100644 --- a/impersonate_login/readme/CONFIGURE.md +++ b/impersonate_login/readme/CONFIGURE.md @@ -3,3 +3,11 @@ The impersonating user must belong to group "Impersonate Users". If you want to prevent impersonation of users with the *Administration: Settings* rights, enable the *Restrict Impersonation of "Administration: Settings" Users* option in the settings. + +Each user also has an *Impersonation* setting, on the *Access Rights* tab of the +user form (administrators only): + +- *Default*: follow the general setting above; +- *Allowed*: the user can always be impersonated, even with the + *Administration: Settings* rights; +- *Forbidden*: the user can never be impersonated. diff --git a/impersonate_login/static/description/index.html b/impersonate_login/static/description/index.html index 8a91815f16..20d0044bf2 100644 --- a/impersonate_login/static/description/index.html +++ b/impersonate_login/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Impersonate Login -
+
+

Impersonate Login

- - -Odoo Community Association - -
-

Impersonate Login

-

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

+

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

This module allows one user (for example, a member of the support team) to log in as another user. The impersonation session can be exited by clicking on the button “Back to Original User”.

@@ -407,14 +402,22 @@

Impersonate Login

-

Configuration

+

Configuration

The impersonating user must belong to group “Impersonate Users”.

If you want to prevent impersonation of users with the Administration: Settings rights, enable the Restrict Impersonation of “Administration: Settings” Users option in the settings.

+

Each user also has an Impersonation setting, on the Access Rights +tab of the user form (administrators only):

+
    +
  • Default: follow the general setting above;
  • +
  • Allowed: the user can always be impersonated, even with the +Administration: Settings rights;
  • +
  • Forbidden: the user can never be impersonated.
  • +
-

Usage

+

Usage

  1. In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click “Switch Login” to @@ -424,7 +427,7 @@

    Usage

-

Bug Tracker

+

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 @@ -432,15 +435,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Akretion
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -465,6 +468,5 @@

Maintainers

-
diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py index 74934e898d..adc5d751d4 100644 --- a/impersonate_login/tests/test_impersonate_login.py +++ b/impersonate_login/tests/test_impersonate_login.py @@ -337,3 +337,50 @@ def test_06_limit_access_to_admin(self): "You cannot impersonate users with " "'Administration: Settings' access rights.", ) + + def _demo_impersonator(self): + self.demo_user.groups_id += self.env.ref( + "impersonate_login.group_impersonate_login" + ) + self.authenticate(user="demo", password="demo") + self.assertEqual(self.session.uid, self.demo_user.id) + + def _restrict_admin_settings(self): + self.env["ir.config_parameter"].sudo().set_param( + "impersonate_login.restrict_impersonate_admin_settings", True + ) + self.admin_user.groups_id += self.env.ref("base.group_system") + + def test_07_forbidden_user(self): + """A user set to Forbidden can never be impersonated.""" + self.admin_user.impersonate_mode = "forbidden" + self._demo_impersonator() + with mute_logger("odoo.http"): + data = self._impersonate_user(self.admin_user) + self.assertEqual( + data["error"]["data"]["message"], "This user cannot be impersonated." + ) + self.assertEqual(self._get_session_info()["result"]["uid"], self.demo_user.id) + + def test_08_allowed_admin_despite_restriction(self): + """An administrator set to Allowed can be impersonated even when the + general setting restricts administrators.""" + self._restrict_admin_settings() + self.admin_user.impersonate_mode = "allowed" + self._demo_impersonator() + data = self._impersonate_user(self.admin_user) + self.assertNotIn("error", data) + self.assertEqual(self._get_session_info()["result"]["uid"], self.admin_user.id) + + def test_09_default_admin_follows_restriction(self): + """Default mode keeps the general setting.""" + self._restrict_admin_settings() + self.assertEqual(self.admin_user.impersonate_mode, "default") + self._demo_impersonator() + with mute_logger("odoo.http"): + data = self._impersonate_user(self.admin_user) + self.assertEqual( + data["error"]["data"]["message"], + "You cannot impersonate users with " + "'Administration: Settings' access rights.", + ) diff --git a/impersonate_login/views/res_users.xml b/impersonate_login/views/res_users.xml index f8019bb5c4..f627dd7e77 100644 --- a/impersonate_login/views/res_users.xml +++ b/impersonate_login/views/res_users.xml @@ -19,4 +19,19 @@ + + res.users + + + + + + + + + From a45afabeaeec2569d2ed9600b9ba4dd75faa2f7f Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sat, 24 Oct 2026 09:40:40 +0700 Subject: [PATCH 2/3] [IMP] impersonate_login: test that a user cannot lift their own protection Signed-off-by: Claude Perrin --- impersonate_login/tests/test_impersonate_login.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py index adc5d751d4..55df47500a 100644 --- a/impersonate_login/tests/test_impersonate_login.py +++ b/impersonate_login/tests/test_impersonate_login.py @@ -4,6 +4,7 @@ import json from uuid import uuid4 +from odoo.exceptions import AccessError from odoo.tests import HttpCase, tagged from odoo.tools import mute_logger @@ -384,3 +385,12 @@ def test_09_default_admin_follows_restriction(self): "You cannot impersonate users with " "'Administration: Settings' access rights.", ) + + def test_10_only_administrators_set_the_mode(self): + """A user cannot lift their own protection.""" + self.demo_user.impersonate_mode = "forbidden" + with self.assertRaises(AccessError): + self.demo_user.with_user(self.demo_user).write( + {"impersonate_mode": "default"} + ) + self.assertEqual(self.demo_user.impersonate_mode, "forbidden") From b6b250628981de5050c566f4350c2c984849709b Mon Sep 17 00:00:00 2001 From: Claude Perrin Date: Sun, 25 Oct 2026 16:44:08 +0700 Subject: [PATCH 3/3] [IMP] impersonate_login: hide users that cannot be impersonated The "Switch Login" list only shows the users that can be switched to, and the row button of the users list is hidden for the others: oneself, users set to Forbidden, and administrators in Default mode when the general setting restricts them. Same rule as the server-side check, which is unchanged, as a computed field with a SQL search. Signed-off-by: Claude Perrin --- impersonate_login/README.rst | 5 +- .../models/res_config_settings.py | 6 + impersonate_login/models/res_users.py | 72 +++++++-- impersonate_login/readme/USAGE.md | 3 + .../static/description/index.html | 5 +- .../tests/test_impersonate_login.py | 151 ++++++++++++++++++ impersonate_login/views/res_users.xml | 6 + 7 files changed, 236 insertions(+), 12 deletions(-) diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst index 2a86fd4271..f37c3b433b 100644 --- a/impersonate_login/README.rst +++ b/impersonate_login/README.rst @@ -73,7 +73,10 @@ Usage 1. In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click "Switch Login" to - impersonate another user. + impersonate another user. Users that cannot be impersonated (set to + *Forbidden*, or set to *Default* with the *Administration: Settings* + rights when the general setting restricts them) are not listed, and + the button is not shown on their row. 2. On the top-right corner, the button "Back to Original User" is displayed in case the current user is being impersonated. diff --git a/impersonate_login/models/res_config_settings.py b/impersonate_login/models/res_config_settings.py index 04fbf9571f..9c76378dd4 100644 --- a/impersonate_login/models/res_config_settings.py +++ b/impersonate_login/models/res_config_settings.py @@ -13,3 +13,9 @@ class ResConfigSettings(models.TransientModel): ), default=False, ) + + def set_values(self): + res = super().set_values() + # Whether a user can be impersonated depends on this setting. + self.env["res.users"].invalidate_model(["impersonate_allowed"]) + return res diff --git a/impersonate_login/models/res_users.py b/impersonate_login/models/res_users.py index 82501dda8d..7b1f548293 100644 --- a/impersonate_login/models/res_users.py +++ b/impersonate_login/models/res_users.py @@ -32,26 +32,75 @@ class Users(models.Model): "Forbidden: this user can never be impersonated.", ) - def _check_can_be_impersonated(self): - """Raise if nobody may impersonate this user.""" + impersonate_allowed = fields.Boolean( + compute="_compute_impersonate_allowed", + search="_search_impersonate_allowed", + string="Can Be Impersonated", + help="Whether the current user may switch to this user: never oneself, " + "a user set to Forbidden, nor (when the general setting restricts " + "them) a user with the 'Administration: Settings' access right.", + ) + + @api.depends("impersonate_mode", "groups_id") + @api.depends_context("uid") + def _compute_impersonate_allowed(self): + for user in self: + user.impersonate_allowed = ( + user.id != self.env.uid and not user._get_impersonation_refusal() + ) + + def _search_impersonate_allowed(self, operator, value): + """Same rule as _get_impersonation_refusal(), as a SQL domain.""" + if operator not in ("=", "!=") or not isinstance(value, bool): + raise UserError(self.env._("Operation not supported")) + domain = [ + ("id", "!=", self.env.uid), + ("impersonate_mode", "!=", "forbidden"), + ] + config_restrict = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("impersonate_login.restrict_impersonate_admin_settings") + ) + if config_restrict: + domain += [ + "|", + ("impersonate_mode", "=", "allowed"), + ("groups_id", "not in", self.env.ref("base.group_system").ids), + ] + # impersonate_mode is readable by administrators only: the subquery is + # built as superuser, its result only says who may be switched to. + query = self.sudo().with_context(active_test=False)._search(domain) + allowed = (operator == "=") == value + return [("id", "in" if allowed else "not in", query)] + + def _get_impersonation_refusal(self): + """Why nobody may impersonate this user (a message), or False.""" self.ensure_one() mode = self.sudo().impersonate_mode if mode == "forbidden": - raise UserError(self.env._("This user cannot be impersonated.")) + return self.env._("This user cannot be impersonated.") if mode == "allowed": - return + return False config_restrict = ( self.env["ir.config_parameter"] .sudo() .get_param("impersonate_login.restrict_impersonate_admin_settings") ) - if config_restrict and self.env.ref("base.group_system") in self.groups_id: - raise UserError( - self.env._( - "You cannot impersonate users with" - " 'Administration: Settings' access rights." - ) + if config_restrict and self.env.ref("base.group_system") in ( + self.sudo().groups_id + ): + return self.env._( + "You cannot impersonate users with" + " 'Administration: Settings' access rights." ) + return False + + def _check_can_be_impersonated(self): + """Raise if nobody may impersonate this user.""" + refusal = self._get_impersonation_refusal() + if refusal: + raise UserError(refusal) def _get_partner_name(self, user_id): return self.env["res.users"].browse(user_id).partner_id.name @@ -119,9 +168,12 @@ def action_impersonate_login(self): "base.action_res_users" ) action["views"] = [[self.env.ref("base.view_users_tree").id, "list"]] + # Only the users that can be switched to: the others would only + # show a button that ends in an error. action["domain"] = [ ("id", "!=", self.env.user.id), ("share", "=", False), + ("impersonate_allowed", "=", True), ] action["target"] = "new" return action diff --git a/impersonate_login/readme/USAGE.md b/impersonate_login/readme/USAGE.md index 348d570009..437a2cdd23 100644 --- a/impersonate_login/readme/USAGE.md +++ b/impersonate_login/readme/USAGE.md @@ -1,4 +1,7 @@ 1. In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click "Switch Login" to impersonate another user. + Users that cannot be impersonated (set to *Forbidden*, or set to *Default* with + the *Administration: Settings* rights when the general setting restricts them) + are not listed, and the button is not shown on their row. 2. On the top-right corner, the button "Back to Original User" is displayed in case the current user is being impersonated. diff --git a/impersonate_login/static/description/index.html b/impersonate_login/static/description/index.html index 20d0044bf2..998059c539 100644 --- a/impersonate_login/static/description/index.html +++ b/impersonate_login/static/description/index.html @@ -421,7 +421,10 @@

Usage

  1. In the menu that is displayed when clicking on the user avatar on the top right corner, or in the res.users list, click “Switch Login” to -impersonate another user.
  2. +impersonate another user. Users that cannot be impersonated (set to +Forbidden, or set to Default with the Administration: Settings +rights when the general setting restricts them) are not listed, and +the button is not shown on their row.
  3. On the top-right corner, the button “Back to Original User” is displayed in case the current user is being impersonated.
diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py index 55df47500a..2fae402acf 100644 --- a/impersonate_login/tests/test_impersonate_login.py +++ b/impersonate_login/tests/test_impersonate_login.py @@ -4,6 +4,8 @@ import json from uuid import uuid4 +from lxml import etree + from odoo.exceptions import AccessError from odoo.tests import HttpCase, tagged from odoo.tools import mute_logger @@ -394,3 +396,152 @@ def test_10_only_administrators_set_the_mode(self): {"impersonate_mode": "default"} ) self.assertEqual(self.demo_user.impersonate_mode, "forbidden") + + def test_11_only_impersonable_users_are_listed(self): + """The "Switch Login" list only shows users that can be impersonated.""" + self._restrict_admin_settings() + forbidden = self.env["res.users"].create( + { + "name": "Forbidden User", + "login": "forbidden_impersonate", + "impersonate_mode": "forbidden", + } + ) + allowed_admin = self.env["res.users"].create( + { + "name": "Allowed Admin", + "login": "allowed_admin_impersonate", + "impersonate_mode": "allowed", + "groups_id": [(4, self.env.ref("base.group_system").id)], + } + ) + self._demo_impersonator() + domain = self._action_impersonate_login()["result"]["domain"] + listed = self.env["res.users"].search(domain) + self.assertIn(allowed_admin, listed) + self.assertNotIn(forbidden, listed) + self.assertNotIn(self.admin_user, listed) # restricted administrator + self.assertNotIn(self.demo_user, listed) # oneself + as_demo = self.env["res.users"].with_user(self.demo_user) + self.assertFalse(as_demo.browse(forbidden.id).impersonate_allowed) + self.assertFalse(as_demo.browse(self.admin_user.id).impersonate_allowed) + self.assertFalse(as_demo.browse(self.demo_user.id).impersonate_allowed) + self.assertTrue(as_demo.browse(allowed_admin.id).impersonate_allowed) + + def test_12_button_hidden_for_users_that_cannot_be_impersonated(self): + """The row button of the users list follows impersonate_allowed.""" + arch = etree.fromstring( + self.env["res.users"] + .with_user(self.admin_user) + .get_view(self.env.ref("base.view_users_tree").id, "list")["arch"] + ) + buttons = arch.xpath("//button[@name='impersonate_login']") + self.assertEqual(len(buttons), 1) + self.assertEqual(buttons[0].get("invisible"), "not impersonate_allowed") + self.assertTrue(arch.xpath("//field[@name='impersonate_allowed']")) + # A non-administrator impersonator gets the same button and its field. + impersonator = self.env["res.users"].create( + { + "name": "Impersonator", + "login": "impersonator_view", + "groups_id": [ + (4, self.env.ref("base.group_user").id), + (4, self.env.ref("base.group_erp_manager").id), + (4, self.env.ref("impersonate_login.group_impersonate_login").id), + ], + } + ) + view_id = self.env.ref("base.view_users_tree").id + arch = etree.fromstring( + self.env["res.users"] + .with_user(impersonator) + .get_view(view_id, "list")["arch"] + ) + self.assertEqual( + arch.xpath("//button[@name='impersonate_login']")[0].get("invisible"), + "not impersonate_allowed", + ) + # Without the group: neither the button nor the field. + self.env.ref("impersonate_login.group_impersonate_login").users -= impersonator + arch = etree.fromstring( + self.env["res.users"] + .with_user(impersonator) + .get_view(view_id, "list")["arch"] + ) + self.assertFalse(arch.xpath("//button[@name='impersonate_login']")) + self.assertFalse(arch.xpath("//field[@name='impersonate_allowed']")) + + def _set_restriction(self, value): + self.env["res.config.settings"].create( + {"restrict_impersonate_admin_settings": value} + ).set_values() + + def test_13_flag_and_list_follow_the_general_setting(self): + """Changing the general setting in the settings changes both the flag + (re-read in the same environment) and who is listed.""" + self.admin_user.groups_id += self.env.ref("base.group_system") + users = self.env["res.users"].with_user(self.demo_user) + target = users.browse(self.admin_user.id) + allowed = [("impersonate_allowed", "=", True)] + self._set_restriction(False) + self.assertTrue(target.impersonate_allowed) + self.assertIn(self.admin_user, users.search(allowed)) + self._set_restriction(True) + self.assertFalse(target.impersonate_allowed) + self.assertNotIn(self.admin_user, users.search(allowed)) + self._set_restriction(False) + self.assertTrue(target.impersonate_allowed) + self.assertIn(self.admin_user, users.search(allowed)) + + def test_14_search_matches_the_computed_flag(self): + """The SQL rule gives the same answer as the computed flag.""" + group_system = self.env.ref("base.group_system") + implying = self.env["res.groups"].create( + {"name": "Implies Settings", "implied_ids": [(4, group_system.id)]} + ) + Users = self.env["res.users"] + users = ( + self.env.ref("base.user_root") + | self.admin_user + | self.demo_user + | Users.create({"name": "Plain", "login": "plain_imp"}) + | Users.create( + {"name": "Implied Admin", "login": "implied_imp", "groups_id": implying} + ) + | Users.create( + {"name": "Inactive Admin", "login": "inactive_imp", "active": False} + ) + | Users.create( + { + "name": "Forbidden", + "login": "forbidden_imp", + "impersonate_mode": "forbidden", + } + ) + | Users.create( + { + "name": "Allowed Admin", + "login": "allowed_imp", + "impersonate_mode": "allowed", + "groups_id": group_system, + } + ) + ) + users.filtered(lambda u: u.login == "inactive_imp").groups_id += group_system + as_demo = Users.with_user(self.demo_user).with_context(active_test=False) + for restrict in (False, True): + self._set_restriction(restrict) + for user in users: + with self.subTest(restrict=restrict, login=user.login): + flag = as_demo.browse(user.id).impersonate_allowed + ids = [("id", "=", user.id)] + for operator, value, expected in ( + ("=", True, flag), + ("!=", True, not flag), + ("=", False, not flag), + ("!=", False, flag), + ): + found = as_demo.search( + ids + [("impersonate_allowed", operator, value)] + ) + self.assertEqual(bool(found), expected, (operator, value)) diff --git a/impersonate_login/views/res_users.xml b/impersonate_login/views/res_users.xml index f627dd7e77..99f4cd8f1c 100644 --- a/impersonate_login/views/res_users.xml +++ b/impersonate_login/views/res_users.xml @@ -9,11 +9,17 @@ list +