From e7a20f3997a0c03511179cad3c23b1f483357554 Mon Sep 17 00:00:00 2001 From: Atte Isopuro Date: Tue, 23 Sep 2025 16:26:54 +0300 Subject: [PATCH 1/2] [MIG] session_redis: Migration to 19.0 * lazy_property is deprecated * Since https://github.com/odoo/odoo/commit/cea66be976c6d0746a972da8a16b7a239d12a462, Session must be used as a dictionary when setting custom values * Session default lifetime is now defined in odoo.http, so we can just reuse that * Handle new session rotation logic (sessions must be findable by the first 42 chars of they key) * Handle new session key generation for rotation (keys must be longer than 42 chars to handle devices) --- session_redis/__manifest__.py | 2 +- session_redis/http.py | 14 ++++- session_redis/session.py | 110 ++++++++++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/session_redis/__manifest__.py b/session_redis/__manifest__.py index a8861c9..de13427 100644 --- a/session_redis/__manifest__.py +++ b/session_redis/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Sessions in Redis", "summary": "Store web sessions in Redis", - "version": "18.0.1.0.0", + "version": "19.0.1.0.0", "author": "Camptocamp,Odoo Community Association (OCA)", "license": "AGPL-3", "category": "Extra Tools", diff --git a/session_redis/http.py b/session_redis/http.py index bb80b9d..efd6c82 100644 --- a/session_redis/http.py +++ b/session_redis/http.py @@ -1,12 +1,11 @@ # Copyright 2016-2024 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) - +import functools import logging import os from odoo import http from odoo.tools import config -from odoo.tools.func import lazy_property from .session import RedisSessionStore from .strtobool import strtobool @@ -46,7 +45,7 @@ ssl_cert_reqs = os.getenv("ODOO_SESSION_REDIS_SSL_CERT_REQS", "1") redis_cluster = os.getenv("ODOO_SESSION_REDIS_CLUSTER", "0") -@lazy_property +@functools.cached_property def session_store(self): if sentinel_host: sentinel = Sentinel([(sentinel_host, sentinel_port)], password=password) @@ -108,5 +107,14 @@ if is_true(os.getenv("ODOO_SESSION_REDIS")): port, ) http.Application.session_store = session_store + # cached_property needs __set_name__ to be called, but it is not called + # automatically since we are attaching the property after instance creation. + # So we have to do it manually + # See: https://docs.python.org/3/reference/datamodel.html#object.__set_name__ + # Credit: https://stackoverflow.com/a/62161136 + http.Application.session_store.__set_name__( + http.Application, + "session_store", + ) # clean the existing sessions on the file system purge_fs_sessions(config.session_dir) diff --git a/session_redis/session.py b/session_redis/session.py index fbc0c34..d45aa5b 100644 --- a/session_redis/session.py +++ b/session_redis/session.py @@ -3,20 +3,29 @@ import json import logging +from typing import TypeAlias, List -from odoo.service import security +import odoo.http +from odoo.http import SESSION_LIFETIME from odoo.tools._vendor.sessions import SessionStore from . import json_encoding -# this is equal to the duration of the session garbage collector in +# this was equal to the duration of the session garbage collector in # odoo.http.session_gc() -DEFAULT_SESSION_TIMEOUT = 60 * 60 * 24 * 7 # 7 days in seconds DEFAULT_SESSION_TIMEOUT_ANONYMOUS = 60 * 60 * 3 # 3 hours in seconds _logger = logging.getLogger(__name__) +# Many parts of the session store API operate not on full session keys, but only +# the first n characters of them (see odoo.http.STORED_SESSION_BYTES). In +# particular used by Devices, but Odoo in general seems to promise that this +# partial sid will be safe to store in the database, and can be used to later +# find sessions, even if those sessions are actually longer. +PartialSid: TypeAlias = str + + class RedisSessionStore(SessionStore): """SessionStore that saves session to redis""" @@ -31,7 +40,7 @@ class RedisSessionStore(SessionStore): super().__init__(session_class=session_class) self.redis = redis if expiration is None: - self.expiration = DEFAULT_SESSION_TIMEOUT + self.expiration = SESSION_LIFETIME else: self.expiration = expiration if anon_expiration is None: @@ -42,18 +51,37 @@ class RedisSessionStore(SessionStore): if prefix: self.prefix = f"{self.prefix}:{prefix}:" + # Use the key generation method of the FileSystemSessionStore: it seems that + # the one on the general SessionStore does not generate long enough keys to + # support the device session rotation logic (SessionStore produces 40 + # character long keys, while the new rotation logic appears to assume a + # length of at least 84). + generate_key = odoo.http.FilesystemSessionStore.generate_key + is_valid_key = odoo.http.FilesystemSessionStore.is_valid_key + def build_key(self, sid): return f"{self.prefix}{sid}" def save(self, session): key = self.build_key(session.sid) - # allow to set a custom expiration for a session + # If the session has a deletion_time, it is slated for rotation, and + # should be removed once the rotation window is over. See + # odoo.http.SESSION_DELETION_TIMER. + # Otherwise, allow to set a custom expiration for a session # such as a very short one for monitoring requests if session.uid: - expiration = session.expiration or self.expiration + expiration = ( + session.get("deletion_time") + or session.get("expiration") + or self.expiration + ) else: - expiration = session.expiration or self.anon_expiration + expiration = ( + session.get("deletion_time") + or session.get("expiration") + or self.anon_expiration + ) if _logger.isEnabledFor(logging.DEBUG): if session.uid: user_msg = f"user '{session.login}' (id: {session.uid})" @@ -106,12 +134,9 @@ class RedisSessionStore(SessionStore): _logger.debug("a listing redis keys has been called") return [key[len(self.prefix) :] for key in keys] - def rotate(self, session, env): - self.delete(session) - session.sid = self.generate_key() - if session.uid and env: - session.session_token = security.compute_session_token(session, env) - self.save(session) + # The FilesystemSessionStore's rotate does not do anything file-system + # specific so it can just be reused here + rotate = odoo.http.FilesystemSessionStore.rotate def vacuum(self, *args, **kwargs): """Do not garbage collect the sessions @@ -120,3 +145,62 @@ class RedisSessionStore(SessionStore): expiration. """ return None + + def delete_old_sessions(self, session): + """ + # Deletion of rotated sessions is handled by updating the sessions' + # expiry based on deletion_time in save(), so this method is redundant + # when using a redis store. + + # While this method is not part of the generic SessionStore API, it is + # defined on the file session store, and is used by the Session itself + # as part of the session rotation (see odoo.http.Session._delete_old_sessions). + """ + return + + def get_missing_session_identifiers(self, identifiers: List[PartialSid]) -> set[PartialSid]: + """ + Given a list of partial session ids, return a set of those session ids + which no longer exist in the keystore. + + While this method is not part of the generic SessionStore API, it is + defined on the file session store, and is used by Odoo's devices to + figure out what needs to be revoked + (see odoo.addons.base.models.res_device.ResDeviceLog.__update_revoked). + """ + identifiers = set(identifiers) + not_found = set() + for partial_sid in identifiers: + try: + next( + self.redis.scan_iter( + match=f"{self.prefix}{partial_sid}*", + count=1, + ) + ) + except StopIteration: + # No matches found + not_found.add(partial_sid) + + return not_found + + def delete_from_identifiers(self, identifiers: List[PartialSid]): + """ + Given a list of partial session ids, remove any that are in the session store. + + While this method is not part of the generic SessionStore API, it is + defined on the file session store, and is used by devices when revoking + device sessions (see odoo.addons.base.models.res_device.ResDevice._revoke). + """ + patterns_to_unlink = [] + for identifier in identifiers: + # Avoid removing a session if it does not match an identifier. + # See this same comment in odoo.http.FileSessionStore.delete_from_identifiers. + if not odoo.http._session_identifier_re.match(identifier): + raise ValueError("Identifier format incorrect, did you pass in a string instead of a list?") + patterns_to_unlink.append(f"{self.prefix}{identifier}*") + keys_to_unlink = [] + for pattern in patterns_to_unlink: + keys_to_unlink.extend(self.redis.scan_iter(match=pattern)) + if keys_to_unlink: + self.redis.delete(*keys_to_unlink) From cd27132ca5544cce6792416efd491098713105e1 Mon Sep 17 00:00:00 2001 From: Odoo Migration Factory Date: Fri, 12 Jun 2026 16:14:05 +0200 Subject: [PATCH 2/2] [FIX] session_redis: mark installable for Enea migration --- session_redis/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/session_redis/__manifest__.py b/session_redis/__manifest__.py index de13427..d720757 100644 --- a/session_redis/__manifest__.py +++ b/session_redis/__manifest__.py @@ -18,5 +18,5 @@ "python": ["redis"], }, "website": "https://github.com/camptocamp/odoo-cloud-platform", - "installable": False, + "installable": True, }