import json import functools import logging import time import uuid import jwt as pyjwt from odoo.http import request _logger = logging.getLogger(__name__) REQUEST_ID_HEADER = "X-Request-Id" def _get_or_create_request_id(): """Return the X-Request-Id for the current request, minting one if absent. The value is cached on ``request`` so the same id is reused for every log line emitted during the request lifecycle (dispatcher, controller, post-dispatch). Call sites should prefer :func:`current_request_id`. """ existing = getattr(request, "_encoach_request_id", None) if existing: return existing header_val = request.httprequest.headers.get(REQUEST_ID_HEADER) req_id = (header_val or uuid.uuid4().hex)[:64] try: request._encoach_request_id = req_id except Exception: pass return req_id def current_request_id(): """Best-effort accessor for the active request id (None outside a request).""" try: return getattr(request, "_encoach_request_id", None) except Exception: return None def _log_json(level, event, **fields): """Emit a single structured JSON log line, enriched with the request id. Keeping all non-interactive logs JSON-formatted lets ops scrape them with Loki / OpenSearch without brittle regexes. Falls back silently if the logging backend rejects the payload. """ try: payload = { "ts": round(time.time(), 3), "event": event, "rid": current_request_id(), } payload.update(fields) _logger.log(level, json.dumps(payload, default=str, ensure_ascii=False)) except Exception: _logger.log(level, "%s %s", event, fields) _jwt_secret_cache = {"secret": None, "ts": 0} # Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation # via ir.config_parameter propagates quickly across all workers without # requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21. _JWT_SECRET_TTL = 30 _user_exists_cache = {} _USER_CACHE_TTL = 60 _USER_CACHE_MAX = 200 def invalidate_jwt_secret_cache(): """Invalidate the per-worker JWT secret cache immediately. Called by ``encoach.auth.settings`` whenever the admin rotates the secret so that subsequent requests pick up the new value on the next read. """ _jwt_secret_cache["secret"] = None _jwt_secret_cache["ts"] = 0 def _get_jwt_secret(): now = time.time() if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL: return _jwt_secret_cache["secret"] secret = ( request.env["ir.config_parameter"] .sudo() .get_param("encoach.jwt_secret") ) if secret: _jwt_secret_cache["secret"] = secret _jwt_secret_cache["ts"] = now return secret def _extract_bearer_token(allow_query_param: bool = False) -> str | None: """Pull a JWT off the current request. By default we only honour the ``Authorization: Bearer …`` header. Some endpoints (notably media streams that get embedded in ```` / ``