fase 3.5: licenciamiento por plan (clave firmada, planes basico/profesional/enterprise/custom, modulos incluidos y limites de usuarios/sucursales/citas/almacenamiento, modo aviso o bloqueo, panel de licencia) + API keys gestionables con alcances y revocacion (arregla la API publica que estaba rota) + webhooks salientes firmados con outbox, reintentos y bitacora; herramientas de vendedor y docs

This commit is contained in:
2026-10-11 19:53:44 +00:00
parent ab9d84cb67
commit 3ed0d327d2
30 changed files with 3705 additions and 89 deletions

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""Autenticación `api_key` para las rutas públicas `/skeen/api/v1/*`.
Odoo no trae un método de autenticación `api_key`; las rutas de
`skeen_whatsapp/controllers/main.py` lo declaraban pero no existía, así que
TODAS las peticiones respondían AccessDenied. Aquí se implementa el método que
falta, sin cambiar ni el formato de la clave ni el de los encabezados:
1) CLave legacy de Hermes: `ir.config_parameter 'skeen.api.legacy_key'`
(sembrada con `skeen_api_key_2026`).
2) Claves del modelo `skeen.api.key` (hash, alcances, vencimiento, revocación).
La clave se lee de `Authorization: Bearer <clave>`, de `X-API-Key` o del
parámetro `api_key` (query/form), que es lo que ya usaba Hermes.
"""
import hmac
import json
from werkzeug.exceptions import Forbidden
from odoo import SUPERUSER_ID, models, http
from odoo.exceptions import AccessDenied
from odoo.http import request, Response
from odoo.addons.skeen_citas.clinic_settings import licencia_de, modulo_bloqueado
LEGACY_KEY_PARAM = 'skeen.api.legacy_key'
LEGACY_KEY_DEFAULT = 'skeen_api_key_2026'
# Rutas del frontend cuyo módulo se controla por licencia. Los módulos con
# bloque `activo` (compras, membresías, comisiones, recursos, notificaciones,
# reservas) ya responden 403 solos al apagarse, así que aquí solo hacen falta
# los que no tienen ese bloque.
RUTAS_LICENCIA = (
('/skeen/frontend/v1/formularios', 'formularios'),
('/skeen/frontend/v1/wacrm', 'whatsapp'),
)
def _respuesta_licencia():
"""Respuesta 403 JSON si la ruta pertenece a un módulo fuera de la licencia.
Devuelve None si la petición puede continuar. No aplica a OPTIONS (preflight
CORS) ni cuando no hay licencia en modo bloqueo."""
try:
if request.httprequest.method == 'OPTIONS':
return None
path = request.httprequest.path or ''
modulo = next((m for prefijo, m in RUTAS_LICENCIA if path.startswith(prefijo)), None)
if not modulo or not request.env:
return None
if not modulo_bloqueado(licencia_de(request.env), modulo):
return None
except Exception:
return None
cuerpo = json.dumps({'status': 'error',
'message': 'Módulo no incluido en tu plan'},
ensure_ascii=False)
return Response(cuerpo, status=403, mimetype='application/json',
headers={'Access-Control-Allow-Origin': '*'})
def extraer_clave(req):
"""Devuelve la clave de API de la petición (o '')."""
try:
headers = req.httprequest.headers
auth = headers.get('Authorization', '') or ''
if auth.lower().startswith('bearer '):
return auth[7:].strip()
for header in ('X-API-Key', 'X-Api-Key', 'Api-Key'):
valor = headers.get(header)
if valor:
return valor.strip()
args = req.httprequest.args
for parametro in ('api_key', 'api-key', 'apikey'):
if args.get(parametro):
return args.get(parametro).strip()
except Exception:
return ''
return ''
def clave_permite_scope(req, scope):
"""True si la clave de la petición concede el alcance `scope`.
La clave legacy de Hermes tiene todos los alcances. Una petición sin
`request.api_key` (p. ej. dentro de pruebas) se considera legacy.
"""
key = getattr(req, 'api_key', None)
if key is None:
return True
return key.permite(scope)
def exigir_scope(req, scope):
"""Lanza 403 (HTTP 403) si la clave no tiene el alcance indicado."""
if not clave_permite_scope(req, scope):
raise Forbidden(description='La clave de API no tiene el alcance "%s".' % scope)
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _dispatch(cls, endpoint):
respuesta = _respuesta_licencia()
if respuesta is not None:
return respuesta
return super()._dispatch(endpoint)
@classmethod
def _auth_method_api_key(cls):
raw = extraer_clave(request)
if not raw:
raise AccessDenied('Falta la clave de API (Authorization: Bearer ...).')
ICP = request.env['ir.config_parameter'].sudo()
legacy = ICP.get_param(LEGACY_KEY_PARAM) or LEGACY_KEY_DEFAULT
if hmac.compare_digest(raw, legacy):
request.api_key = None
request.api_key_legacy = True
request.update_env(user=SUPERUSER_ID)
return
key = None
if 'skeen.api.key' in request.env:
key = request.env['skeen.api.key'].sudo()._autenticar(raw)
if not key:
raise AccessDenied('Clave de API inválida o revocada.')
request.api_key = key
request.api_key_legacy = False
key._registrar_uso()
request.update_env(user=SUPERUSER_ID)