diff --git a/impersonate_login/README.rst b/impersonate_login/README.rst new file mode 100644 index 0000000000..7f1ad88d75 --- /dev/null +++ b/impersonate_login/README.rst @@ -0,0 +1,116 @@ +================= +Impersonate Login +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1fca331cbc5f2dcb804e5612e5669a9ab4998d80f22d46d6683266580f9ca40f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/impersonate_login + :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-impersonate_login + :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 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". + +To ensure that any abuse of this feature will not go unnoticed, the +following measures are in place: + +- In the chatter, it is displayed who is the user that is logged as + another user. +- Mails and messages are sent from the original user. +- Impersonated logins are logged and can be consulted through the + Settings -> Technical menu. +- + +There is an alternative module to allow logins as another user +(auth_admin_passkey), but it does not support these security mechanisms. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The impersonating user must belong to group "Impersonate Users". + +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. On the top-right corner, the button "Back to Original User" is + displayed in case the current user is being impersonated. + +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 +------- + +* Akretion + +Contributors +------------ + +- Kévin Roche +- `360ERP `__: + + - Andrea Stirpe + +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. + +.. |maintainer-Kev-Roche| image:: https://github.com/Kev-Roche.png?size=40px + :target: https://github.com/Kev-Roche + :alt: Kev-Roche + +Current `maintainer `__: + +|maintainer-Kev-Roche| + +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/impersonate_login/__init__.py b/impersonate_login/__init__.py new file mode 100644 index 0000000000..6d58305f5d --- /dev/null +++ b/impersonate_login/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/impersonate_login/__manifest__.py b/impersonate_login/__manifest__.py new file mode 100644 index 0000000000..e07a936e97 --- /dev/null +++ b/impersonate_login/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Impersonate Login", + "summary": "tools", + "version": "18.0.1.0.0", + "category": "Tools", + "website": "https://github.com/OCA/server-auth", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["Kev-Roche"], + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "web", + "mail", + ], + "data": [ + "security/group.xml", + "security/ir.model.access.csv", + "views/res_users.xml", + "views/impersonate_log.xml", + ], + "assets": { + "web.assets_backend": [ + "impersonate_login/static/src/js/user_menu.esm.js", + ], + }, + "pre_init_hook": "pre_init_hook", +} diff --git a/impersonate_login/hooks.py b/impersonate_login/hooks.py new file mode 100644 index 0000000000..a9897368ff --- /dev/null +++ b/impersonate_login/hooks.py @@ -0,0 +1,19 @@ +# Copyright 2024 360ERP () +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + + +def pre_init_hook(env): + """ + Pre-create the impersonated_author_id column in the mail_message table + to prevent the ORM from invoking its compute method on a large volume + of existing mail messages. + """ + logger = logging.getLogger(__name__) + logger.info("Add mail_message.impersonated_author_id column if not exists") + env.cr.execute( + "ALTER TABLE mail_message " + "ADD COLUMN IF NOT EXISTS " + "impersonated_author_id INTEGER" + ) diff --git a/impersonate_login/i18n/impersonate_login.pot b/impersonate_login/i18n/impersonate_login.pot new file mode 100644 index 0000000000..1242a14bfb --- /dev/null +++ b/impersonate_login/i18n/impersonate_login.pot @@ -0,0 +1,154 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * impersonate_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: impersonate_login +#. odoo-javascript +#: code:addons/impersonate_login/static/src/js/user_menu.esm.js:0 +#, python-format +msgid "Back to Original User" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_base +msgid "Base" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_mail_mail__body +#: model:ir.model.fields,field_description:impersonate_login.field_mail_message__body +msgid "Contents" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__create_uid +msgid "Created by" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__create_date +msgid "Created on" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__display_name +msgid "Display Name" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_mail_thread +msgid "Email Thread" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__date_end +msgid "End Date" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_ir_http +msgid "HTTP Routing" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__id +msgid "ID" +msgstr "" + +#. module: impersonate_login +#: model:ir.actions.act_window,name:impersonate_login.impersonate_log_action +msgid "Impersonate Login Logs" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_impersonate_log +msgid "Impersonate Logs" +msgstr "" + +#. module: impersonate_login +#: model:res.groups,name:impersonate_login.group_impersonate_login +msgid "Impersonate Users" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_mail_mail__impersonated_author_id +#: model:ir.model.fields,field_description:impersonate_login.field_mail_message__impersonated_author_id +msgid "Impersonated Author" +msgstr "" + +#. module: impersonate_login +#: model:ir.ui.menu,name:impersonate_login.menu_impersonate_log +msgid "Impersonated Logs" +msgstr "" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/res_users.py:0 +#, python-format +msgid "It's you." +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__write_date +msgid "Last Updated on" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__impersonated_partner_id +msgid "Logged as" +msgstr "" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/mail_message.py:0 +#: code:addons/impersonate_login/models/mail_message.py:0 +#, python-format +msgid "Logged in as {}" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_mail_message +msgid "Message" +msgstr "" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__date_start +msgid "Start Date" +msgstr "" + +#. module: impersonate_login +#. odoo-javascript +#: code:addons/impersonate_login/static/src/js/user_menu.esm.js:0 +#: model_terms:ir.ui.view,arch_db:impersonate_login.impersonate_res_users_tree +#, python-format +msgid "Switch Login" +msgstr "" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_res_users +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__user_id +msgid "User" +msgstr "" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/res_users.py:0 +#, python-format +msgid "You are already Logged as another user." +msgstr "" diff --git a/impersonate_login/i18n/it.po b/impersonate_login/i18n/it.po new file mode 100644 index 0000000000..11c1d69216 --- /dev/null +++ b/impersonate_login/i18n/it.po @@ -0,0 +1,156 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * impersonate_login +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-08-30 14:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: impersonate_login +#. odoo-javascript +#: code:addons/impersonate_login/static/src/js/user_menu.esm.js:0 +#, python-format +msgid "Back to Original User" +msgstr "Riporta a utente originale" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_base +msgid "Base" +msgstr "Base" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_mail_mail__body +#: model:ir.model.fields,field_description:impersonate_login.field_mail_message__body +msgid "Contents" +msgstr "Contenuti" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_mail_thread +msgid "Email Thread" +msgstr "Discussione e-mail" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__date_end +msgid "End Date" +msgstr "Data fine" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_ir_http +msgid "HTTP Routing" +msgstr "Instradamento HTTP" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__id +msgid "ID" +msgstr "ID" + +#. module: impersonate_login +#: model:ir.actions.act_window,name:impersonate_login.impersonate_log_action +msgid "Impersonate Login Logs" +msgstr "Imita registri di accesso" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_impersonate_log +msgid "Impersonate Logs" +msgstr "Imita registri" + +#. module: impersonate_login +#: model:res.groups,name:impersonate_login.group_impersonate_login +msgid "Impersonate Users" +msgstr "Imita utenti" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_mail_mail__impersonated_author_id +#: model:ir.model.fields,field_description:impersonate_login.field_mail_message__impersonated_author_id +msgid "Impersonated Author" +msgstr "Imita autore" + +#. module: impersonate_login +#: model:ir.ui.menu,name:impersonate_login.menu_impersonate_log +msgid "Impersonated Logs" +msgstr "Imita registri" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/res_users.py:0 +#, python-format +msgid "It's you." +msgstr "Sei tu." + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__impersonated_partner_id +msgid "Logged as" +msgstr "Registrato come" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/mail_message.py:0 +#, python-format +msgid "Logged in as {}" +msgstr "Registrato come {}" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_mail_message +msgid "Message" +msgstr "Messaggio" + +#. module: impersonate_login +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__date_start +msgid "Start Date" +msgstr "Data inizio" + +#. module: impersonate_login +#. odoo-javascript +#: code:addons/impersonate_login/static/src/js/user_menu.esm.js:0 +#: model_terms:ir.ui.view,arch_db:impersonate_login.impersonate_res_users_tree +#, python-format +msgid "Switch Login" +msgstr "Scambia accesso" + +#. module: impersonate_login +#: model:ir.model,name:impersonate_login.model_res_users +#: model:ir.model.fields,field_description:impersonate_login.field_impersonate_log__user_id +msgid "User" +msgstr "Utente" + +#. module: impersonate_login +#. odoo-python +#: code:addons/impersonate_login/models/res_users.py:0 +#, python-format +msgid "You are already Logged as another user." +msgstr "Si è già registrati come altro utente." diff --git a/impersonate_login/models/__init__.py b/impersonate_login/models/__init__.py new file mode 100644 index 0000000000..debb66e9c1 --- /dev/null +++ b/impersonate_login/models/__init__.py @@ -0,0 +1,6 @@ +from . import res_users +from . import ir_http +from . import mail_thread +from . import mail_message +from . import impersonate_log +from . import model diff --git a/impersonate_login/models/impersonate_log.py b/impersonate_login/models/impersonate_log.py new file mode 100644 index 0000000000..83089611a6 --- /dev/null +++ b/impersonate_login/models/impersonate_log.py @@ -0,0 +1,25 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ImpersonateLog(models.Model): + _name = "impersonate.log" + _description = "Impersonate Logs" + + user_id = fields.Many2one( + comodel_name="res.users", + ) + impersonated_partner_id = fields.Many2one( + comodel_name="res.partner", + string="Logged as", + ) + date_start = fields.Datetime( + string="Start Date", + ) + date_end = fields.Datetime( + string="End Date", + ) diff --git a/impersonate_login/models/ir_http.py b/impersonate_login/models/ir_http.py new file mode 100644 index 0000000000..f01aa613d2 --- /dev/null +++ b/impersonate_login/models/ir_http.py @@ -0,0 +1,20 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class Http(models.AbstractModel): + _inherit = "ir.http" + + def session_info(self): + session_info = super().session_info() + session_info.update( + { + "is_impersonate_user": request.env.user._is_impersonate_user(), + "impersonate_from_uid": request.session.impersonate_from_uid, + } + ) + return session_info diff --git a/impersonate_login/models/mail_message.py b/impersonate_login/models/mail_message.py new file mode 100644 index 0000000000..e7bf2fd4dc --- /dev/null +++ b/impersonate_login/models/mail_message.py @@ -0,0 +1,79 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.http import request +from odoo.tools import html_escape + + +class Message(models.Model): + _inherit = "mail.message" + + impersonated_author_id = fields.Many2one( + comodel_name="res.partner", + compute="_compute_impersonated_author_id", + store=True, + ) + + body = fields.Html( + compute="_compute_message_body", + inverse="_inverse_message_body", + store=True, + readonly=False, + ) + + @api.depends("author_id") + def _compute_impersonated_author_id(self): + for rec in self: + if request and request.session.impersonate_from_uid: + rec.impersonated_author_id = ( + self.env["res.users"] + .browse(request.session.impersonate_from_uid) + .partner_id.id + ) + else: + rec.impersonated_author_id = False + + @api.depends("author_id", "impersonated_author_id") + def _compute_message_body(self): + for rec in self: + additional_info = "" + if ( + request + and request.session.impersonate_from_uid + and rec.impersonated_author_id + ): + current_partner = ( + self.env["res.users"].browse(request.session.uid).partner_id + ) + additional_info = _("Logged in as {}").format( + html_escape(current_partner.name) + ) + if rec.body and additional_info: + rec.body = f"{additional_info}
{rec.body}" + else: + rec.body = rec.body + + def _inverse_message_body(self): + for rec in self: + additional_info = "" + if ( + request + and request.session.impersonate_from_uid + and rec.impersonated_author_id + ): + current_partner = ( + self.env["res.users"].browse(request.session.uid).partner_id + ) + additional_info = _("Logged in as {}").format( + html_escape(current_partner.name) + ) + if additional_info: + start_with = f"{additional_info}
" + if rec.body and rec.body.startswith(start_with): + rec.body = rec.body + else: + rec.body = f"{start_with}{rec.body}" + else: + rec.body = rec.body diff --git a/impersonate_login/models/mail_thread.py b/impersonate_login/models/mail_thread.py new file mode 100644 index 0000000000..03e21cca97 --- /dev/null +++ b/impersonate_login/models/mail_thread.py @@ -0,0 +1,30 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class MailThread(models.AbstractModel): + _inherit = "mail.thread" + + def _message_compute_author( + self, author_id=None, email_from=None, raise_on_email=True + ): + if request and request.session.impersonate_from_uid: + author = self.env["res.users"].browse(request.session.uid).partner_id + if author_id == author.id or author_id is None: + impersonate_from_author = ( + self.env["res.users"] + .browse(request.session.impersonate_from_uid) + .partner_id + ) + email = impersonate_from_author.email_formatted + return impersonate_from_author.id, email + + return super()._message_compute_author( + author_id=author_id, + email_from=email_from, + raise_on_email=raise_on_email, + ) diff --git a/impersonate_login/models/model.py b/impersonate_login/models/model.py new file mode 100644 index 0000000000..726c57ccad --- /dev/null +++ b/impersonate_login/models/model.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Akretion (). +# @author Kévin Roche +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.http import request + + +class BaseModel(models.AbstractModel): + _inherit = "base" + + def _prepare_create_values(self, vals_list): + result_vals_list = super()._prepare_create_values(vals_list) + if ( + request + and request.session.impersonate_from_uid + and "create_uid" in self._fields + ): + for vals in result_vals_list: + vals["create_uid"] = request.session.impersonate_from_uid + return result_vals_list + + def write(self, vals): + """Overwrite the write_uid with the impersonating user""" + res = super().write(vals) + if ( + request + and request.session.impersonate_from_uid + and "write_uid" in self._fields + ): + self._fields["write_uid"].write(self, request.session.impersonate_from_uid) + return res diff --git a/impersonate_login/models/res_users.py b/impersonate_login/models/res_users.py new file mode 100644 index 0000000000..a805db0bff --- /dev/null +++ b/impersonate_login/models/res_users.py @@ -0,0 +1,120 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Kévin Roche +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.http import request +from odoo.service import security + +logger = logging.getLogger(__name__) + + +class Users(models.Model): + _inherit = "res.users" + + def _get_partner_name(self, user_id): + return self.env["res.users"].browse(user_id).partner_id.name + + def _is_impersonate_user(self): + self.ensure_one() + return self.has_group("impersonate_login.group_impersonate_login") + + def impersonate_login(self): + if request: + if request.session.impersonate_from_uid: + if self.id == request.session.impersonate_from_uid: + return self.back_to_origin_login() + else: + raise UserError(_("You are already Logged as another user.")) + if self.id == request.session.uid: + raise UserError(_("It's you.")) + if ( + request.env.user._is_impersonate_user() + and request.env.user._is_internal() + ): + target_uid = self.id + request.session.impersonate_from_uid = self._uid + request.session.uid = target_uid + impersonate_log = ( + self.env["impersonate.log"] + .sudo() + .create( + { + "user_id": self._uid, + "impersonated_partner_id": self.env["res.users"] + .browse(target_uid) + .partner_id.id, + "date_start": fields.datetime.now(), + } + ) + ) + request.session.impersonate_log_id = impersonate_log.id + logger.info( + f"IMPERSONATE: {self._get_partner_name(self._uid)} " + f"Login as {self._get_partner_name(self.id)}" + ) + # invalidate session token cache as we've changed the uid + request.env.registry.clear_cache() + request.session.session_token = security.compute_session_token( + request.session, request.env + ) + + # reload the client; open the first available root menu + menu = self.env["ir.ui.menu"].search([("parent_id", "=", False)])[:1] + return { + "type": "ir.actions.client", + "tag": "reload", + "params": {"menu_id": menu.id}, + } + + @api.model + def action_impersonate_login(self): + if request: + from_uid = request.session.impersonate_from_uid + if not from_uid: + action = self.env["ir.actions.act_window"]._for_xml_id( + "base.action_res_users" + ) + action["views"] = [[self.env.ref("base.view_users_tree").id, "list"]] + action["domain"] = [ + ("id", "!=", self.env.user.id), + ("share", "=", False), + ] + action["target"] = "new" + return action + + @api.model + def back_to_origin_login(self): + if request: + from_uid = request.session.impersonate_from_uid + if from_uid: + request.session.uid = from_uid + self.env["impersonate.log"].sudo().browse( + request.session.impersonate_log_id + ).write( + { + "date_end": fields.datetime.now(), + } + ) + # invalidate session token cache as we've changed the uid + request.env.registry.clear_cache() + request.session.impersonate_from_uid = False + request.session.impersonate_log_id = False + request.session.session_token = security.compute_session_token( + request.session, request.env + ) + logger.info( + f"IMPERSONATE: {self._get_partner_name(from_uid)} " + f"Logout as {self._get_partner_name(self._uid)}" + ) + + # reload the client; open the first available root menu + menu = self.env["ir.ui.menu"].search([("parent_id", "=", False)])[:1] + return { + "type": "ir.actions.client", + "tag": "reload", + "params": {"menu_id": menu.id}, + } diff --git a/impersonate_login/pyproject.toml b/impersonate_login/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/impersonate_login/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/impersonate_login/readme/CONFIGURE.md b/impersonate_login/readme/CONFIGURE.md new file mode 100644 index 0000000000..33ebb6904a --- /dev/null +++ b/impersonate_login/readme/CONFIGURE.md @@ -0,0 +1 @@ +The impersonating user must belong to group "Impersonate Users". diff --git a/impersonate_login/readme/CONTRIBUTORS.md b/impersonate_login/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..03d53dd05a --- /dev/null +++ b/impersonate_login/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Kévin Roche \<\> +- [360ERP](https://www.360erp.com): + - Andrea Stirpe diff --git a/impersonate_login/readme/DESCRIPTION.md b/impersonate_login/readme/DESCRIPTION.md new file mode 100644 index 0000000000..cee4996cfb --- /dev/null +++ b/impersonate_login/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +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". + +To ensure that any abuse of this feature will not go unnoticed, the following measures are in place: + +* In the chatter, it is displayed who is the user that is logged as another user. +* Mails and messages are sent from the original user. +* Impersonated logins are logged and can be consulted through the Settings -> Technical menu. +* +There is an alternative module to allow logins as another user (auth_admin_passkey), +but it does not support these security mechanisms. diff --git a/impersonate_login/readme/USAGE.md b/impersonate_login/readme/USAGE.md new file mode 100644 index 0000000000..348d570009 --- /dev/null +++ b/impersonate_login/readme/USAGE.md @@ -0,0 +1,4 @@ +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. 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/security/group.xml b/impersonate_login/security/group.xml new file mode 100644 index 0000000000..e996175066 --- /dev/null +++ b/impersonate_login/security/group.xml @@ -0,0 +1,13 @@ + + + + + Impersonate Users + + + diff --git a/impersonate_login/security/ir.model.access.csv b/impersonate_login/security/ir.model.access.csv new file mode 100644 index 0000000000..3a5c10c53d --- /dev/null +++ b/impersonate_login/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_impersonate_log,impersonate logs,model_impersonate_log,base.group_user,1,1,0,0 diff --git a/impersonate_login/static/description/index.html b/impersonate_login/static/description/index.html new file mode 100644 index 0000000000..ef7209996d --- /dev/null +++ b/impersonate_login/static/description/index.html @@ -0,0 +1,459 @@ + + + + + +Impersonate Login + + + +
+

Impersonate Login

+ + +

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

+

To ensure that any abuse of this feature will not go unnoticed, the +following measures are in place:

+
    +
  • In the chatter, it is displayed who is the user that is logged as +another user.
  • +
  • Mails and messages are sent from the original user.
  • +
  • Impersonated logins are logged and can be consulted through the +Settings -> Technical menu.
  • +
  • +
+

There is an alternative module to allow logins as another user +(auth_admin_passkey), but it does not support these security mechanisms.

+

Table of contents

+ +
+

Configuration

+

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

+
+
+

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. +
  3. On the top-right corner, the button “Back to Original User” is +displayed in case the current user is being impersonated.
  4. +
+
+
+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

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.

+

Current maintainer:

+

Kev-Roche

+

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/impersonate_login/static/src/js/user_menu.esm.js b/impersonate_login/static/src/js/user_menu.esm.js new file mode 100644 index 0000000000..b467c47f17 --- /dev/null +++ b/impersonate_login/static/src/js/user_menu.esm.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; +import {session} from "@web/session"; + +export function impersonateLoginItem(env) { + return { + type: "item", + id: "impersonate_login", + description: _t("Switch Login"), + hide: session.impersonate_from_uid || !session.is_impersonate_user, + callback: async function () { + const actionImpersonateLogin = await env.services.orm.call( + "res.users", + "action_impersonate_login" + ); + env.services.action.doAction(actionImpersonateLogin); + }, + sequence: 55, + }; +} + +export function impersonateBackLoginItem(env) { + return { + type: "item", + id: "impersonate_back", + description: _t("Back to Original User"), + hide: !session.impersonate_from_uid, + callback: async function () { + const actionBackToOriginLogin = await env.services.orm.call( + "res.users", + "back_to_origin_login" + ); + env.services.action.doAction(actionBackToOriginLogin); + }, + sequence: 55, + }; +} + +registry + .category("user_menuitems") + .add("impersonate_login", impersonateLoginItem, {force: true}) + .add("impersonate_back", impersonateBackLoginItem, {force: true}); diff --git a/impersonate_login/tests/__init__.py b/impersonate_login/tests/__init__.py new file mode 100644 index 0000000000..d2f02c982b --- /dev/null +++ b/impersonate_login/tests/__init__.py @@ -0,0 +1 @@ +from . import test_impersonate_login diff --git a/impersonate_login/tests/test_impersonate_login.py b/impersonate_login/tests/test_impersonate_login.py new file mode 100644 index 0000000000..b6a4f352eb --- /dev/null +++ b/impersonate_login/tests/test_impersonate_login.py @@ -0,0 +1,265 @@ +# Copyright 2024 360ERP () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import json +from uuid import uuid4 + +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger + + +@tagged("post_install", "-at_install") +class TestImpersonateLogin(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.admin_user = cls.env.ref("base.user_admin") + cls.demo_user = cls.env.ref("base.user_demo") + + def _impersonate_user(self, user): + response = self.url_open( + "/web/dataset/call_button", + data=json.dumps( + { + "params": { + "model": "res.users", + "method": "impersonate_login", + "args": [user.id], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _action_impersonate_login(self): + response = self.url_open( + "/web/dataset/call_button", + data=json.dumps( + { + "params": { + "model": "res.users", + "method": "action_impersonate_login", + "args": [], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _get_session_info(self): + response = self.url_open( + "/web/session/get_session_info", + data=json.dumps(dict(jsonrpc="2.0", method="call", id=str(uuid4()))), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def test_01_admin_impersonates_user_demo(self): + """Admin user impersonates Demo user""" + # Login as admin + self.authenticate(user="admin", password="admin") + self.assertEqual(self.session.uid, self.admin_user.id) + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Switch Login button + data = self._action_impersonate_login() + result = data["result"] + self.assertEqual(result["target"], "new") + + # Impersonate demo user + data = self._impersonate_user(self.demo_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.demo_user.login) + self.assertFalse(result["is_system"]) + self.assertFalse(result["is_admin"]) + self.assertFalse(result["is_impersonate_user"]) + self.assertEqual(result["impersonate_from_uid"], self.admin_user.id) + + # Check impersonate log + log1 = self.env["impersonate.log"].search([], order="id desc", limit=1) + self.assertTrue(log1.date_start) + self.assertFalse(log1.date_end) + + # Impersonate demo user again: error + with mute_logger("odoo.http"): + data = self._impersonate_user(self.demo_user) + result = data["error"] + self.assertEqual( + result["data"]["message"], "You are already Logged as another user." + ) + + # Back to original user + data = self._impersonate_user(self.admin_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Check impersonate log + log2 = self.env["impersonate.log"].search([], order="id desc", limit=1) + self.assertEqual(log1, log2) + self.assertTrue(log1.date_start) + self.assertTrue(log1.date_end) + + def test_02_user_demo_impersonates_admin(self): + """Demo user impersonates Admin user""" + # Login as demo user + self.authenticate(user="demo", password="demo") + self.assertEqual(self.session.uid, self.demo_user.id) + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertFalse(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + # Impersonate demo user: is already current user + self.demo_user.groups_id += self.env.ref( + "impersonate_login.group_impersonate_login" + ) + with mute_logger("odoo.http"): + data = self._impersonate_user(self.demo_user) + result = data["error"] + self.assertEqual(result["data"]["message"], "It's you.") + + # Impersonate admin user + data = self._impersonate_user(self.admin_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.admin_user.login) + self.assertTrue(result["is_system"]) + self.assertTrue(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertEqual(result["impersonate_from_uid"], self.demo_user.id) + + # Impersonate admin user again: error + with mute_logger("odoo.http"): + data = self._impersonate_user(self.admin_user) + result = data["error"] + self.assertEqual( + result["data"]["message"], "You are already Logged as another user." + ) + + # Back to original user + data = self._impersonate_user(self.demo_user) + result = data["result"] + self.assertEqual(result["tag"], "reload") + + # Check get_session_info() + data = self._get_session_info() + result = data["result"] + self.assertEqual(result["username"], self.demo_user.login) + self.assertFalse(result["is_system"]) + self.assertFalse(result["is_admin"]) + self.assertTrue(result["is_impersonate_user"]) + self.assertFalse(result["impersonate_from_uid"]) + + def test_03_create_uid(self): + """Check the create_uid of records created + during an impersonated session""" + # Login as admin + self.authenticate(user="admin", password="admin") + + # Impersonate demo user and create a contact + self._impersonate_user(self.demo_user) + + response = self.url_open( + "/web/dataset/call_kw/res.partner/web_save", + data=json.dumps( + { + "params": { + "model": "res.partner", + "method": "web_save", + "args": [ + [], + { + "name": "Contact123", + }, + {}, + ], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + result = data["result"] + contact_id = result[0]["id"] + + contact = self.env["res.partner"].browse(contact_id) + self.assertEqual(contact.name, "Contact123") + self.assertEqual(contact.create_uid, self.admin_user) + + def test_04_write_uid(self): + """Check the write_uid of records created + during an impersonated session""" + # Login as admin + self.authenticate(user="admin", password="admin") + + # Create a contact + contact = self.env["res.partner"].create({"name": "ContactABC"}) + + # Impersonate demo user and modify a contact + self._impersonate_user(self.demo_user) + + response = self.url_open( + "/web/dataset/call_kw/res.partner/web_save", + data=json.dumps( + { + "params": { + "model": "res.partner", + "method": "web_save", + "args": [ + [contact.id], + { + "ref": "abc", + }, + {}, + ], + "kwargs": {}, + }, + } + ), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + result = data["result"] + contact_id = result[0]["id"] + + self.assertEqual(contact.id, contact_id) + self.assertEqual(contact.ref, "abc") + self.assertEqual(contact.write_uid, self.admin_user) diff --git a/impersonate_login/views/impersonate_log.xml b/impersonate_login/views/impersonate_log.xml new file mode 100644 index 0000000000..c633ef855b --- /dev/null +++ b/impersonate_login/views/impersonate_log.xml @@ -0,0 +1,36 @@ + + + + + impersonate.log.tree + impersonate.log + + + + + + + + + + + + + Impersonate Login Logs + impersonate.log + + list + + + + + diff --git a/impersonate_login/views/res_users.xml b/impersonate_login/views/res_users.xml new file mode 100644 index 0000000000..f8019bb5c4 --- /dev/null +++ b/impersonate_login/views/res_users.xml @@ -0,0 +1,22 @@ + + + + + res.users + + list + + +