Merge commit 'refs/pull/499/head' of github.com:camptocamp/odoo-cloud-platform into merge-branch-3606-master-80b5c2d0

This commit is contained in:
jcoux
2025-11-17 12:26:28 +01:00
3 changed files with 109 additions and 17 deletions
+1 -1
View File
@@ -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",
+11 -3
View File
@@ -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)
+97 -13
View File
@@ -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)