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 validate_token(): """Decode JWT Bearer token and return the corresponding ``res.users`` record or None.""" auth_header = request.httprequest.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return None token = auth_header[7:] secret = _get_jwt_secret() if not secret: _logger.error("System parameter 'encoach.jwt_secret' is not configured") return None try: payload = pyjwt.decode(token, secret, algorithms=["HS256"]) except pyjwt.ExpiredSignatureError: return None except pyjwt.InvalidTokenError: return None # Refresh tokens are only accepted by /api/auth/refresh. Presenting one as # a Bearer on any other endpoint is treated as unauthenticated so a # leaked refresh token never buys API access on its own. token_type = payload.get("type") if token_type and token_type != "access": return None user_id = payload.get("user_id") if not user_id: return None now = time.time() cache_key = int(user_id) cached = _user_exists_cache.get(cache_key) if cached and (now - cached["ts"]) < _USER_CACHE_TTL: return request.env["res.users"].sudo().browse(cache_key) user = request.env["res.users"].sudo().browse(cache_key) if not user.exists(): return None _user_exists_cache[cache_key] = {"ts": now} if len(_user_exists_cache) > _USER_CACHE_MAX: oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"]) del _user_exists_cache[oldest_key] return user def jwt_required(func): """Decorator that validates the JWT token and sets request.env user context. Also handles per-request observability plumbing: - Assigns (or honours) an ``X-Request-Id`` so every log line, downstream service call, and response can be correlated back to the same request. - Emits a structured ``api.request.start`` / ``api.request.end`` log pair with duration in ms, status code, user id, route, and method — powering the P2.3 metrics pipeline once it lands without any extra wiring. - Echoes the request id back to the caller via the ``X-Request-Id`` response header. """ @functools.wraps(func) def wrapper(*args, **kwargs): req_id = _get_or_create_request_id() route = request.httprequest.path method = request.httprequest.method t0 = time.time() user = validate_token() if not user: _log_json(logging.INFO, "api.request.unauthorized", route=route, method=method, status=401) resp = _error_response("Authentication required", status=401) try: resp.headers[REQUEST_ID_HEADER] = req_id except Exception: pass return resp request.update_env(user=user.id) _log_json(logging.INFO, "api.request.start", route=route, method=method, user_id=user.id) try: resp = func(*args, **kwargs) status = getattr(resp, "status_code", 200) duration_ms = int((time.time() - t0) * 1000) _log_json(logging.INFO, "api.request.end", route=route, method=method, user_id=user.id, status=status, duration_ms=duration_ms) try: resp.headers[REQUEST_ID_HEADER] = req_id except Exception: pass _record_metric(route, status, duration_ms) return resp except Exception as exc: duration_ms = int((time.time() - t0) * 1000) _log_json(logging.ERROR, "api.request.error", route=route, method=method, user_id=user.id, duration_ms=duration_ms, error=type(exc).__name__, message=str(exc)[:500]) _record_metric(route, 500, duration_ms) raise return wrapper def _record_metric(route, status, duration_ms): """Soft import of the metrics recorder so this module has no hard cycle.""" try: from odoo.addons.encoach_api.controllers.openapi import record_request record_request(route, status, duration_ms) except Exception: pass def _json_response(data, status=200): resp = request.make_json_response(data, status=status) try: rid = current_request_id() if rid: resp.headers[REQUEST_ID_HEADER] = rid except Exception: pass return resp def _error_response(message, status=400, code=None): body = {"error": message} if code: body["code"] = code resp = request.make_json_response(body, status=status) try: rid = current_request_id() if rid: resp.headers[REQUEST_ID_HEADER] = rid except Exception: pass return resp def _get_json_body(): try: return json.loads(request.httprequest.get_data(as_text=True)) except (ValueError, TypeError): return {} def paginated_envelope(items, *, total=None, page=None, size=None, extra=None): """Canonical ``{items, total, page, size}`` envelope. This is the platform-wide pagination shape — prefer returning the output of this helper from any new list endpoint. Legacy keys (``data``, ``results``, and custom aliases such as ``students`` / ``subjects``) may be added via the ``extra`` dict for transitional backward compatibility with the frontend services that still read those keys; new code must read ``items``. """ items = list(items or []) envelope = { "items": items, "total": int(total if total is not None else len(items)), } if page is not None: envelope["page"] = int(page) if size is not None: envelope["size"] = int(size) if extra: for k, v in extra.items(): if k not in envelope: envelope[k] = v return envelope def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'): """Paginate an Odoo model search or extract params from a kwargs dict. Two calling conventions: _paginate(Model, domain, page, size, order) → (recordset, total_count) _paginate(kwargs_dict) → (offset, limit, page) """ if isinstance(model_or_kwargs, dict): kwargs = model_or_kwargs limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200) page_raw = int(kwargs.get("page", 0)) pg = max(page_raw, 0) offset = pg * limit return offset, limit, pg Model = model_or_kwargs limit = min(max(int(size), 1), 200) pg = max(int(page), 0) offset = pg * limit total = Model.search_count(domain or []) records = Model.search(domain or [], offset=offset, limit=limit, order=order) return records, total class EncoachMixin: """Shared authentication and response helpers for all EnCoach API controllers.""" def _get_jwt_secret(self): return _get_jwt_secret() def _authenticate(self): return validate_token() def _json_response(self, data, status=200): return _json_response(data, status=status) def _error_response(self, message, status=400, code=None): return _error_response(message, status=status, code=code) def _get_json_body(self): return _get_json_body() def _paginate_params(self, kwargs): return _paginate(kwargs) def _serialize(self, record): if hasattr(record, "to_encoach_dict"): return record.to_encoach_dict() return {"id": record.id} def _serialize_list(self, records): return [self._serialize(r) for r in records]