diff --git a/frappe/auth.py b/frappe/auth.py index ea7eb34b05..dd7f76d938 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -163,12 +163,12 @@ class LoginManager: frappe.form_dict.pop("pwd", None) self.post_login() - def post_login(self): + def post_login(self, session_end: str | None = None, audit_user: str | None = None): self.run_trigger("on_login") validate_ip_address(self.user) self.validate_hour() self.get_user_info() - self.make_session() + self.make_session(session_end=session_end, audit_user=audit_user) self.setup_boot_cache() self.set_user_info() @@ -216,10 +216,17 @@ class LoginManager: def clear_preferred_language(self): frappe.local.cookie_manager.delete_cookie("preferred_language") - def make_session(self, resume=False): + def make_session( + self, resume: bool = False, session_end: str | None = None, audit_user: str | None = None + ): # start session frappe.local.session_obj = Session( - user=self.user, resume=resume, full_name=self.full_name, user_type=self.user_type + user=self.user, + resume=resume, + full_name=self.full_name, + user_type=self.user_type, + session_end=session_end, + audit_user=audit_user, ) # reset user if changed to Guest @@ -339,15 +346,16 @@ class LoginManager: """login as guest""" self.login_as("Guest") - def login_as(self, user): + def login_as(self, user: str, session_end: str | None = None, audit_user: str | None = None): self.user = user - self.post_login() + self.post_login(session_end, audit_user) def impersonate(self, user): current_user = frappe.session.user - self.login_as(user) + session_data = frappe.local.session_obj.data.data + self.login_as(user, session_end=session_data.session_end, audit_user=session_data.audit_user) # Flag this session as impersonated session, so other code can log this. - frappe.local.session_obj.set_impersonsated(current_user) + frappe.local.session_obj.set_impersonated(current_user) def logout(self, arg="", user=None): if not user: diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 5a8c53e840..7a858f62ec 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1175,8 +1175,20 @@ def publish_realtime(context: CliCtxObj, event, message, room, user, doctype, do @click.command("browse") @click.argument("site", required=False) @click.option("--user", required=False, help="Login as user") +@click.option( + "--session-end", + required=False, + help="Session end (in ISO8601 format and timezone-aware - 2025-01-24T12:26:29.200853+00:00)", +) +@click.option("--user-for-audit", required=False, help="The user to mention in audit trail") @pass_context -def browse(context: CliCtxObj, site, user=None): +def browse( + context: CliCtxObj, + site, + user: str | None = None, + session_end: str | None = None, + user_for_audit: str | None = None, +): """Opens the site on web browser""" from frappe.auth import CookieManager, LoginManager @@ -1202,7 +1214,7 @@ def browse(context: CliCtxObj, site, user=None): frappe.utils.set_request(path="/") frappe.local.cookie_manager = CookieManager() frappe.local.login_manager = LoginManager() - frappe.local.login_manager.login_as(user) + frappe.local.login_manager.login_as(user, session_end, user_for_audit) sid = f"/app?sid={frappe.session.sid}" else: click.echo("Please enable developer mode to login as a user") diff --git a/frappe/core/doctype/version/version.py b/frappe/core/doctype/version/version.py index 39eff56a75..3ad33021c7 100644 --- a/frappe/core/doctype/version/version.py +++ b/frappe/core/doctype/version/version.py @@ -37,6 +37,9 @@ class Version(Document): if impersonator := frappe.session.data.get("impersonated_by"): data["impersonated_by"] = impersonator + if audit_user := frappe.session.data.get("audit_user"): + data["audit_user"] = audit_user + def set_diff(self, old: Document, new: Document) -> bool: """Set the data property with the diff of the docs if present""" diff = get_diff(old, new) diff --git a/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js b/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js index 43d55fe5da..7e6a5526c7 100644 --- a/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js +++ b/frappe/public/js/frappe/form/footer/version_timeline_content_builder.js @@ -246,6 +246,12 @@ function get_version_timeline_content(version_doc, frm) { const impersonated_msg = __("Impersonated by {0}", [get_user_link(impersonated_by)]); out = out.map((message) => `${message} · ${impersonated_msg.bold()}`); } + + const audit_user = data.audit_user; + if (audit_user) { + const audit_msg = __("[Action taken by {0}]", [audit_user]); + out = out.map((message) => `${message} · ${audit_msg.bold()}`); + } return out; } diff --git a/frappe/sessions.py b/frappe/sessions.py index 6329b1320d..8404405148 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -8,6 +8,7 @@ permission, homepage, default variables, system defaults etc """ import json +from datetime import datetime, timezone from urllib.parse import unquote import redis @@ -205,7 +206,15 @@ def generate_csrf_token(): class Session: __slots__ = ("_update_in_cache", "data", "full_name", "sid", "time_diff", "user", "user_type") - def __init__(self, user, resume=False, full_name=None, user_type=None): + def __init__( + self, + user: str, + resume: bool = False, + full_name: str | None = None, + user_type: str | None = None, + session_end: str | None = None, + audit_user: str | None = None, + ): self.sid = cstr( frappe.form_dict.pop("sid", None) or unquote(frappe.request.cookies.get("sid", "Guest")) ) @@ -225,7 +234,7 @@ class Session: else: if self.user: self.validate_user() - self.start() + self.start(session_end, audit_user) def validate_user(self): if not frappe.get_cached_value("User", self.user, "enabled"): @@ -234,7 +243,7 @@ class Session: frappe.ValidationError, ) - def start(self): + def start(self, session_end: str | None = None, audit_user: str | None = None): """start a new session""" # generate sid if self.user == "Guest": @@ -246,6 +255,13 @@ class Session: self.sid = self.data.sid = sid self.data.data.user = self.user self.data.data.session_ip = frappe.local.request_ip + + if session_end: + self.data.data.session_end = session_end + + if audit_user: + self.data.data.audit_user = audit_user + if self.user != "Guest": self.data.data.update( { @@ -347,7 +363,10 @@ class Session: ) expiry = get_expiry_in_seconds(session_data.get("session_expiry")) - if self.time_diff > expiry: + if self.time_diff > expiry or ( + (session_end := session_data.get("session_end")) + and datetime.now(tz=timezone.utc) > datetime.fromisoformat(session_end) + ): self._delete_session() data = None @@ -420,7 +439,7 @@ class Session: return updated_in_db - def set_impersonsated(self, original_user): + def set_impersonated(self, original_user): self.data.data.impersonated_by = original_user # Forcefully flush session self.update(force=True)