diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst index 658ce207ec..f37c3b433b 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,12 +60,23 @@ 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 ===== 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 0703234d87..7b1f548293 100644 --- a/impersonate_login/models/res_users.py +++ b/impersonate_login/models/res_users.py @@ -15,6 +15,93 @@ 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.", + ) + + 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": + return self.env._("This user cannot be impersonated.") + if mode == "allowed": + 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.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 @@ -24,20 +111,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() @@ -93,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/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/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 8a91815f16..998059c539 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,24 +402,35 @@

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 -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.
-

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 +438,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 +471,5 @@

Maintainers

-
diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py index 74934e898d..2fae402acf 100644 --- a/impersonate_login/tests/test_impersonate_login.py +++ b/impersonate_login/tests/test_impersonate_login.py @@ -4,6 +4,9 @@ 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 @@ -337,3 +340,208 @@ 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.", + ) + + 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") + + 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 f8019bb5c4..99f4cd8f1c 100644 --- a/impersonate_login/views/res_users.xml +++ b/impersonate_login/views/res_users.xml @@ -9,14 +9,35 @@ list +