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

@@ -10,8 +10,13 @@ El nombre por defecto es el del brand (`skeen.clinic.brand` -> 'nombre') para no
cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import base64
import hashlib
import hmac
import json
import os
import re
from datetime import date
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
@@ -855,6 +860,385 @@ def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: SIN licencia aplicada (`clave=''`) NADA cambia en la instancia. Además,
# con `modo='aviso'` (el default) la licencia SOLO advierte: no esconde menús,
# no apaga módulos y no impone límites. Solo con `modo='bloqueo'` y una clave
# válida se aplican los módulos incluidos y los límites.
#
# plan: basico | profesional | enterprise | custom (informativo + default de módulos)
# modo: aviso (default) | bloqueo
# cliente: nombre del cliente al que se licenció
# vence: 'YYYY-MM-DD' (vacío = sin vencimiento)
# clave: clave firmada por el vendedor (ver generar_licencia.py)
# modulos: módulos incluidos; VACÍO = todos los módulos disponibles
# limites: topes de uso; 0 = sin límite (default -> nada cambia)
#
# La licencia NUNCA bloquea lo indispensable (login, agenda, configuración):
# solo aplica a los módulos "gateables" y a los límites indicados.
# ---------------------------------------------------------------------------
LICENCIA_DEFAULT = {
'plan': '',
'modo': 'aviso',
'cliente': '',
'vence': '',
'clave': '',
'modulos': [],
'limites': {
'usuarios_max': 0,
'sucursales_max': 0,
'citas_mes_max': 0,
'almacenamiento_mb': 0,
},
}
LICENCIA_PLANES = ('basico', 'profesional', 'enterprise', 'custom')
LICENCIA_MODOS = ('aviso', 'bloqueo')
LIMITE_KEYS = ('usuarios_max', 'sucursales_max', 'citas_mes_max', 'almacenamiento_mb')
# Módulos que una licencia puede incluir/excluir. Los "gateables" son los que
# tienen un bloque de configuración con `activo` (se apagan solos) o una guarda
# explícita (whatsapp, formularios).
MODULOS_GATEABLES = (
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
'membresias', 'compras', 'comisiones', 'formularios',
)
# Módulos siempre incluidos (no se pueden quitar: son la base de la clínica).
MODULOS_BASE = ('agenda', 'pacientes', 'pos', 'inventario', 'reportes')
MODULO_LABEL = {
'agenda': 'Agenda', 'pacientes': 'Pacientes y expediente', 'pos': 'Punto de venta',
'inventario': 'Inventario', 'reportes': 'Reportes', 'whatsapp': 'WhatsApp y WACRM',
'notificaciones': 'Notificaciones', 'reservas_online': 'Reservas en línea',
'recursos': 'Recursos de la agenda', 'membresias': 'Membresías',
'compras': 'Compras y proveedores', 'comisiones': 'Comisiones',
'formularios': 'Formularios digitales',
}
# Módulo gateable -> bloque de clinic-settings cuyo `activo` se apaga al quedar
# fuera de la licencia. whatsapp y formularios no tienen bloque: se guardan por
# controlador (ver skeen_plataforma y los controladores de cada módulo).
BLOQUE_POR_MODULO = {
'notificaciones': 'notificaciones',
'reservas_online': 'reservas_online',
'recursos': 'recursos',
'membresias': 'membresias',
'compras': 'compras',
'comisiones': 'comisiones',
}
# Planes predefinidos (tabla de producto). `modulos` = lista explícita; el CLI
# de licencias la usa como default cuando no se pasan --modulos.
PLANES = {
'basico': {
'label': 'Básico',
'modulos': list(MODULOS_BASE),
},
'profesional': {
'label': 'Profesional',
'modulos': list(MODULOS_BASE) + [
'whatsapp', 'notificaciones', 'reservas_online', 'recursos',
],
},
'enterprise': {
'label': 'Enterprise',
'modulos': list(MODULOS_BASE) + list(MODULOS_GATEABLES),
},
'custom': {
'label': 'A medida',
'modulos': [],
},
}
def modulos_de_plan(plan):
"""Módulos por defecto de un plan (lista; vacía = todos)."""
return list((PLANES.get(plan) or {}).get('modulos') or [])
def normalizar_licencia(raw):
"""Bloque `licencia` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LICENCIA_DEFAULT)
out['limites'] = dict(LICENCIA_DEFAULT['limites'])
out['modulos'] = []
if not isinstance(raw, dict):
return out
plan = str(raw.get('plan') or '').strip().lower()
out['plan'] = plan if plan in LICENCIA_PLANES else ''
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LICENCIA_MODOS else LICENCIA_DEFAULT['modo']
out['cliente'] = str(raw.get('cliente') or '').strip()[:120]
out['vence'] = str(raw.get('vence') or '').strip()[:10]
out['clave'] = str(raw.get('clave') or '').strip()
modulos = raw.get('modulos')
if isinstance(modulos, (list, tuple)):
limpios = []
for m in modulos:
m = str(m or '').strip().lower()
if m and m in (MODULOS_BASE + MODULOS_GATEABLES) and m not in limpios:
limpios.append(m)
out['modulos'] = limpios
limites = raw.get('limites')
if isinstance(limites, dict):
for k in LIMITE_KEYS:
out['limites'][k] = max(0, _int(limites.get(k), 0))
return out
def validar_licencia(raw):
"""Valida el bloque `licencia` de un PUT directo. Devuelve SOLO las claves
presentes para que el merge no reinicie lo que el PUT no tocó.
La verificación de la FIRMA de la clave NO vive aquí (depende de la clave
del vendedor): la hace el controlador antes de guardar."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de licencia debe ser un objeto')
out = {}
if 'plan' in raw:
plan = str(raw['plan'] or '').strip().lower()
if plan and plan not in LICENCIA_PLANES:
raise ValueError('El plan debe ser básico, profesional, enterprise o custom')
out['plan'] = plan
if 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LICENCIA_MODOS:
raise ValueError("El modo debe ser 'aviso' o 'bloqueo'")
out['modo'] = modo
for k, limite in (('cliente', 120), ('vence', 10)):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > limite:
raise ValueError('El campo %s es demasiado largo' % k)
out[k] = raw[k].strip()
if 'clave' in raw:
if not isinstance(raw['clave'], str):
raise ValueError('La clave de licencia debe ser texto')
out['clave'] = raw['clave'].strip()
if 'modulos' in raw:
valor = raw['modulos']
if not isinstance(valor, list):
raise ValueError('Los módulos deben ser una lista')
limpios = []
for m in valor:
m = str(m or '').strip().lower()
if m not in (MODULOS_BASE + MODULOS_GATEABLES):
raise ValueError('Módulo desconocido: %s' % m)
if m not in limpios:
limpios.append(m)
out['modulos'] = limpios
if 'limites' in raw:
valor = raw['limites']
if not isinstance(valor, dict):
raise ValueError('Los límites deben ser un objeto')
limpios = {}
for k, v in valor.items():
if k not in LIMITE_KEYS:
raise ValueError('Límite desconocido: %s' % k)
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Los límites deben ser números enteros')
if v < 0:
raise ValueError('Los límites no pueden ser negativos')
limpios[k] = v
out['limites'] = limpios
return out
def modulos_licencia(licencia):
"""Módulos permitidos por una licencia. None = todos (sin restricción)."""
if not isinstance(licencia, dict):
return None
if not licencia.get('clave'):
return None
modulos = licencia.get('modulos') or []
if not modulos:
return None
return set(modulos)
def licencia_bloqueo_activo(licencia):
"""True si la licencia está en modo bloqueo y tiene clave aplicada."""
return bool(isinstance(licencia, dict) and licencia.get('clave')
and licencia.get('modo') == 'bloqueo')
def modulo_bloqueado(licencia, clave):
"""True si el módulo gateable `clave` queda fuera de la licencia."""
if clave not in MODULOS_GATEABLES:
return False
if not licencia_bloqueo_activo(licencia):
return False
permitidos = modulos_licencia(licencia)
if permitidos is None:
return False
return clave not in permitidos
def licencia_de(env):
"""Bloque de licencia vigente (copia)."""
return get_settings(env).get('licencia') or dict(LICENCIA_DEFAULT)
def modulo_habilitado(env, clave):
"""True si el módulo `clave` está habilitado (sin licencia = todo habilitado)."""
return not modulo_bloqueado(licencia_de(env), clave)
def limite_licencia(env, clave):
"""Tope configurado para `clave` (0 = sin límite)."""
return int((licencia_de(env).get('limites') or {}).get(clave) or 0)
def _aplicar_licencia(out):
"""Apaga los bloques `activo` de los módulos fuera de la licencia.
Se aplica SOLO con modo=bloqueo y clave válida; así el resto del sistema
(endpoints, menús y validaciones que leen `activo`) reacciona solo. Con
`aviso` o sin licencia, `out` queda intacto."""
licencia = out.get('licencia') or {}
if not licencia_bloqueo_activo(licencia):
return
for modulo, bloque in BLOQUE_POR_MODULO.items():
if modulo_bloqueado(licencia, modulo) and isinstance(out.get(bloque), dict):
out[bloque]['activo'] = False
# ---------------------------------------------------------------------------
# Clave de licencia firmada (HMAC-SHA256 con la clave del vendedor).
#
# Formato compacto:
# SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
#
# `payload_json` usa claves ordenadas y separadores mínimos, para que el CLI del
# vendedor (infra/licencias/generar_licencia.py, Python stdlib) genere
# exactamente los mismos bytes. La clave del vendedor se configura en la
# instancia por:
# 1) ir.config_parameter 'skeen.licencia.vendor_key'
# 2) variable de entorno SKEEN_LICENCIA_VENDOR_KEY
# 3) archivo /root/.skeen-licencias/vendor.key (permisos 600)
# Sin clave de vendedor configurada, ninguna licencia se puede validar (y sin
# licencia aplicada el sistema se comporta como siempre).
# ---------------------------------------------------------------------------
LICENCIA_PREFIJO = 'SKN1'
LICENCIA_VENDOR_KEY_ICP = 'skeen.licencia.vendor_key'
LICENCIA_VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
LICENCIA_VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
def vendor_key(env=None):
"""Clave del vendedor vigente (ICP -> entorno -> archivo). '' si no hay."""
key = ''
if env is not None:
try:
key = env['ir.config_parameter'].sudo().get_param(LICENCIA_VENDOR_KEY_ICP) or ''
except Exception:
key = ''
if not key:
key = os.environ.get(LICENCIA_VENDOR_KEY_ENV) or ''
if not key:
try:
with open(LICENCIA_VENDOR_KEY_FILE, 'r', encoding='utf-8') as fh:
key = (fh.read() or '').strip()
except OSError:
key = ''
return key
def _b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(texto):
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
def _payload_canonico(payload):
return json.dumps(payload, sort_keys=True, separators=(',', ':'),
ensure_ascii=False).encode('utf-8')
def firmar_licencia(payload, vendor):
"""Devuelve la clave de licencia firmada (mismo algoritmos que el CLI)."""
cuerpo = _b64url(_payload_canonico(payload))
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
return '%s.%s.%s' % (LICENCIA_PREFIJO, cuerpo, _b64url(firma))
def verificar_clave_licencia(env, clave):
"""Verifica firma y vigencia. Devuelve (payload, error).
`payload` es el dict de la licencia si la clave es válida; `error` es un
mensaje claro de texto si no lo es (uno de los dos es None)."""
clave = (clave or '').strip()
if not clave:
return None, 'No se recibió ninguna clave de licencia.'
partes = clave.split('.')
if len(partes) != 3 or partes[0] != LICENCIA_PREFIJO:
return None, 'La clave de licencia no tiene el formato esperado.'
vendor = vendor_key(env)
if not vendor:
return None, ('No hay clave de vendedor configurada en esta instancia '
'(skeen.licencia.vendor_key).')
_, cuerpo, firma = partes
esperada = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
try:
recibida = _b64url_decode(firma)
except Exception:
return None, 'La firma de la clave de licencia está corrupta.'
if not hmac.compare_digest(esperada, recibida):
return None, 'La firma de la clave no es válida (clave manipulada).'
try:
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
except Exception:
return None, 'El contenido de la clave de licencia está corrupto.'
if not isinstance(payload, dict):
return None, 'El contenido de la clave de licencia no es válido.'
vence = str(payload.get('vence') or '').strip()
if vence:
try:
d = date.fromisoformat(vence)
except ValueError:
return None, 'La fecha de vencimiento de la clave no es válida.'
if d < date.today():
return None, 'Esta licencia venció el %s.' % vence
return payload, None
def licencia_desde_payload(payload, clave, modo=None):
"""Bloque `licencia` a guardar a partir de una clave ya validada."""
payload = payload or {}
plan = str(payload.get('plan') or '').strip().lower()
modulos = payload.get('modulos')
if not isinstance(modulos, list):
modulos = modulos_de_plan(plan) if plan in PLANES else []
return normalizar_licencia({
'plan': plan if plan in LICENCIA_PLANES else 'custom',
'modo': modo or 'bloqueo',
'cliente': payload.get('cliente') or '',
'vence': payload.get('vence') or '',
'clave': clave,
'modulos': modulos,
'limites': payload.get('limites') or {},
})
def dias_para_vencer(licencia):
"""Días restantes hasta el vencimiento (None si no hay fecha)."""
vence = str((licencia or {}).get('vence') or '').strip()
if not vence:
return None
try:
return (date.fromisoformat(vence) - date.today()).days
except ValueError:
return None
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -879,8 +1263,12 @@ def brand_nombre(env):
return nombre or BRAND_NOMBRE_DEFAULT
def get_settings(env):
"""Objeto completo de ajustes, con defaults si no hay nada guardado."""
def get_settings(env, aplicar_licencia=True):
"""Objeto completo de ajustes, con defaults si no hay nada guardado.
`aplicar_licencia=False` devuelve lo GUARDADO sin apagar por licencia (lo
necesita set_settings: si persistiera el estado ya apagado por la licencia,
quitar la licencia no restauraría los módulos)."""
out = {
'nombre': brand_nombre(env),
'telefono': '',
@@ -901,6 +1289,7 @@ def get_settings(env):
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -919,6 +1308,9 @@ def get_settings(env):
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
if aplicar_licencia:
_aplicar_licencia(out)
return out
@@ -926,7 +1318,7 @@ def set_settings(env, vals):
"""Guarda mergeando sobre lo actual. Devuelve el objeto resultante."""
if not isinstance(vals, dict):
raise ValueError('Los ajustes deben ser un objeto')
actual = get_settings(env)
actual = get_settings(env, aplicar_licencia=False)
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
@@ -961,6 +1353,14 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['comisiones'] = {**actual['comisiones'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:
# Merge anidado; los límites se mergean por clave. La verificación
# de la FIRMA de la clave la hace el controlador `/licencia` antes
# de llegar aquí (ver skeen_plataforma/controllers/licencia.py).
limites = {**actual['licencia'].get('limites', {}), **bloque.pop('limites', {})}
actual['licencia'] = {**actual['licencia'], **bloque, 'limites': limites}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)