feat(backend): Phase 2/3 hardening release

Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 14:16:09 +04:00
parent 1a0349c381
commit 3972023a30
64 changed files with 4121 additions and 701 deletions

View File

@@ -1,5 +1,25 @@
"""Authentication endpoints.
Split-token design:
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
Sent on every request as ``Authorization: Bearer <access_token>``.
* ``refresh_token`` — long-lived (7d), stateful, backed by
``encoach.jwt.token``. Never sent on regular API calls; only to
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
usable at most once.
The frontend should store ``refresh_token`` in a secure, non-extractable
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
otherwise ``localStorage`` behind an explicit trust boundary) and call
``/api/auth/refresh`` in the background whenever the access token is within
2 minutes of expiry.
"""
import logging
import time
import uuid
from datetime import datetime, timedelta
import jwt as pyjwt
@@ -13,6 +33,40 @@ from .base import (
_logger = logging.getLogger(__name__)
ACCESS_TTL_SECONDS = 3600 # 1 hour
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
Persisting the refresh token is the caller's job — they get the ``jti``
back so they can insert exactly one row.
"""
secret = _get_jwt_secret()
now = int(time.time())
access_token = pyjwt.encode(
{
"user_id": user.id,
"type": "access",
"iat": now,
"exp": now + ACCESS_TTL_SECONDS,
},
secret, algorithm="HS256",
)
refresh_jti = uuid.uuid4().hex
refresh_token = pyjwt.encode(
{
"user_id": user.id,
"type": "refresh",
"jti": refresh_jti,
"iat": now,
"exp": now + REFRESH_TTL_SECONDS,
},
secret, algorithm="HS256",
)
return access_token, refresh_token, refresh_jti
class EncoachAuthController(http.Controller):
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
if not login or not password:
return _error_response('login and password are required', 400)
# Odoo 19: session.authenticate(env, credential_dict)
credential = {
'type': 'password',
'login': login,
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
user = request.env['res.users'].sudo().browse(uid)
# Generate JWT token
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
token = pyjwt.encode(
{'user_id': user.id, 'exp': int(time.time()) + 86400},
secret, algorithm='HS256',
)
# User-Agent / IP are best-effort — some proxies strip them; we
# record whatever we get but never refuse login because they're
# missing.
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
request.env['encoach.jwt.token'].sudo().create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
# Get permissions
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
# Update last login
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'token': token,
'token': access_token, # legacy name (frontend fallback)
'access_token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
'user': self._user_to_dict(user),
'permissions': permissions,
})
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
_logger.exception('login failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/auth/refresh
# ------------------------------------------------------------------
@http.route('/api/auth/refresh', type='http', auth='public',
methods=['POST'], csrf=False)
def refresh(self, **kw):
"""Rotate a refresh token into a fresh access+refresh pair.
Consumes (revokes) the presented refresh token and issues new ones.
This is the *only* place a refresh token is ever accepted, so replay
attempts against other endpoints fail at the access-token layer.
"""
try:
body = _get_json_body()
token = body.get('refresh_token') or ''
if not token:
return _error_response('refresh_token is required', 400)
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
try:
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
except pyjwt.ExpiredSignatureError:
return _error_response('refresh_token expired', 401)
except pyjwt.InvalidTokenError:
return _error_response('refresh_token invalid', 401)
if claims.get('type') != 'refresh':
return _error_response('wrong token type', 401)
jti = claims.get('jti')
user_id = claims.get('user_id')
if not jti or not user_id:
return _error_response('refresh_token malformed', 401)
Token = request.env['encoach.jwt.token'].sudo()
row = Token.search([
('jti', '=', jti),
('user_id', '=', user_id),
('revoked', '=', False),
], limit=1)
if not row:
# Either already rotated (possible replay) or never existed.
# Revoke every active token for that user as a defensive
# measure — stolen refresh tokens shouldn't buy multiple
# rotations.
Token.search([
('user_id', '=', user_id),
('revoked', '=', False),
]).write({'revoked': True})
return _error_response('refresh_token revoked', 401)
user = request.env['res.users'].sudo().browse(user_id)
if not user.exists() or not user.active:
return _error_response('user disabled', 401)
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
ip = request.httprequest.remote_addr if request.httprequest else ''
access_token, refresh_token, refresh_jti = _issue_tokens(
user, user_agent=ua, remote_ip=ip,
)
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
Token.create({
'jti': refresh_jti,
'user_id': user.id,
'issued_at': fields.Datetime.now(),
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
'user_agent': (ua or '')[:255],
'remote_ip': (ip or '')[:64],
})
return _json_response({
'access_token': access_token,
'token': access_token,
'refresh_token': refresh_token,
'expires_in': ACCESS_TTL_SECONDS,
'refresh_expires_in': REFRESH_TTL_SECONDS,
'token_type': 'Bearer',
})
except Exception as e:
_logger.exception('refresh failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/user (returns current authenticated user)
# ------------------------------------------------------------------
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
# JWT is stateless — client clears token. Server just returns OK.
"""Revoke the presented refresh token (if any) and return OK.
Access tokens remain stateless; they expire naturally within an hour.
Clients that care about immediate lockout should also delete the
access token from local storage when they call this endpoint.
"""
try:
body = _get_json_body() or {}
token = body.get('refresh_token') or ''
if token:
secret = _get_jwt_secret()
if secret:
try:
claims = pyjwt.decode(
token, secret, algorithms=['HS256'],
options={'verify_exp': False},
)
jti = claims.get('jti')
if jti:
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
except pyjwt.InvalidTokenError:
# Invalid tokens can't be revoked but also can't be
# reused, so this is effectively a no-op.
pass
except Exception:
_logger.exception('logout cleanup failed')
return _json_response({'ok': True})
# ------------------------------------------------------------------