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,219 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Generador de claves de licencia SKEEN (herramienta del vendedor).
Vive FUERA del producto: no se despliega en las instancias de las clínicas.
Solo el vendedor corre este script para emitir/verificar una clave. La clave se
firma con HMAC-SHA256 usando la clave del vendedor; la instancia valida la firma
con la misma clave (symmetric HMAC), por lo que la clave del vendedor debe estar
configurada en cada instancia que aplique licencias:
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)
Formato de la clave (texto, una sola línea):
SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor))>
`payload_json` = JSON con claves ordenadas y separadores mínimos:
{\"cliente\":...,\"limites\":{...},\"modulos\":[...],\"plan\":...,\"vence\":...}
Uso:
generar_licencia.py generar --cliente "Spa Bella" --plan profesional \
--vence 2027-12-31 [--modulos whatsapp,recursos] \
[--usuarios-max N --sucursales-max N --citas-mes-max N --almacenamiento-mb N]
generar_licencia.py verificar --clave "SKN1...."
El plan define los módulos por default; --modulos los pisa. En los planes
`basico`/`profesional`/`enterprise` la lista de módulos es explícita; `custom`
usa exactamente la lista indicada (vacía = todos los módulos).
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import sys
from datetime import date
PREFIJO = 'SKN1'
VENDOR_KEY_ENV = 'SKEEN_LICENCIA_VENDOR_KEY'
VENDOR_KEY_FILE = '/root/.skeen-licencias/vendor.key'
MODULOS_BASE = ['agenda', 'pacientes', 'pos', 'inventario', 'reportes']
MODULOS_GATEABLES = ['whatsapp', 'notificaciones', 'reservas_online', 'recursos',
'membresias', 'compras', 'comisiones', 'formularios']
MODULOS_VALIDOS = MODULOS_BASE + MODULOS_GATEABLES
PLANES = {
'basico': MODULOS_BASE,
'profesional': MODULOS_BASE + ['whatsapp', 'notificaciones',
'reservas_online', 'recursos'],
'enterprise': MODULOS_BASE + MODULOS_GATEABLES,
'custom': [], # lo que diga --modulos; vacío = todos
}
def cargar_vendor(secreto=None, archivo=None):
if secreto:
return secreto.strip()
env = (os.environ.get(VENDOR_KEY_ENV) or '').strip()
if env:
return env
ruta = archivo or VENDOR_KEY_FILE
try:
with open(ruta, 'r', encoding='utf-8') as fh:
return (fh.read() or '').strip()
except OSError:
return ''
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(payload, vendor):
cuerpo = _b64url(_payload_canonico(payload))
firma = hmac.new(vendor.encode('utf-8'), cuerpo.encode('ascii'),
hashlib.sha256).digest()
return '%s.%s.%s' % (PREFIJO, cuerpo, _b64url(firma))
def verificar(clave, vendor):
partes = (clave or '').strip().split('.')
if len(partes) != 3 or partes[0] != PREFIJO:
return None, 'Formato de clave inválido.'
_, 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, 'Firma corrupta.'
if not hmac.compare_digest(esperada, recibida):
return None, 'La firma no es válida (clave manipulada).'
try:
payload = json.loads(_b64url_decode(cuerpo).decode('utf-8'))
except Exception:
return None, 'Contenido corrupto.'
vence = str(payload.get('vence') or '').strip()
if vence:
try:
if date.fromisoformat(vence) < date.today():
return payload, 'La licencia está vencida (venció el %s).' % vence
except ValueError:
return payload, 'Fecha de vencimiento inválida.'
return payload, None
def cmd_generar(args):
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
if not vendor:
print('ERROR: no hay clave de vendedor. Configura %s o el archivo %s.'
% (VENDOR_KEY_ENV, VENDOR_KEY_FILE), file=sys.stderr)
return 2
plan = (args.plan or 'custom').strip().lower()
if plan not in PLANES:
print('ERROR: plan inválido (basico|profesional|enterprise|custom).',
file=sys.stderr)
return 2
if args.modulos:
modulos = []
for m in args.modulos.split(','):
m = m.strip().lower()
if m not in MODULOS_VALIDOS:
print('ERROR: módulo desconocido: %s' % m, file=sys.stderr)
return 2
if m not in modulos:
modulos.append(m)
else:
modulos = list(PLANES[plan])
limites = {
'usuarios_max': args.usuarios_max or 0,
'sucursales_max': args.sucursales_max or 0,
'citas_mes_max': args.citas_mes_max or 0,
'almacenamiento_mb': args.almacenamiento_mb or 0,
}
payload = {
'v': 1,
'cliente': args.cliente or '',
'plan': plan,
'vence': (args.vence or '').strip(),
'emitida': date.today().isoformat(),
'modulos': modulos,
'limites': limites,
}
clave = firmar(payload, vendor)
if args.json:
print(json.dumps({'clave': clave, 'payload': payload},
ensure_ascii=False, indent=2))
else:
print(clave)
print('', file=sys.stderr)
print('Cliente: %s | Plan: %s | Vence: %s'
% (payload['cliente'] or '-', plan, payload['vence'] or 'sin vencimiento'),
file=sys.stderr)
print('Módulos: %s' % (', '.join(modulos) or 'todos'), file=sys.stderr)
print('Límites: %s' % limites, file=sys.stderr)
return 0
def cmd_verificar(args):
vendor = cargar_vendor(args.secreto, args.secreto_archivo)
if not vendor:
print('ERROR: no hay clave de vendedor.', file=sys.stderr)
return 2
payload, error = verificar(args.clave, vendor)
if args.json:
print(json.dumps({'valida': error is None, 'error': error,
'payload': payload}, ensure_ascii=False, indent=2))
else:
print('VÁLIDA' if error is None else 'INVÁLIDA: %s' % error)
if payload:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if error is None else 1
def main(argv=None):
parser = argparse.ArgumentParser(description='Generador de licencias SKEEN')
parser.add_argument('--secreto', help='clave del vendedor (o env/archivo)')
parser.add_argument('--secreto-archivo', dest='secreto_archivo',
help='ruta alterna del archivo con la clave del vendedor')
sub = parser.add_subparsers(dest='cmd', required=True)
g = sub.add_parser('generar', help='emite una clave de licencia')
g.add_argument('--cliente', default='')
g.add_argument('--plan', default='custom',
help='basico | profesional | enterprise | custom')
g.add_argument('--vence', default='', help='YYYY-MM-DD (vacío = sin vencimiento)')
g.add_argument('--modulos', default='', help='lista separada por comas')
g.add_argument('--usuarios-max', dest='usuarios_max', type=int, default=0)
g.add_argument('--sucursales-max', dest='sucursales_max', type=int, default=0)
g.add_argument('--citas-mes-max', dest='citas_mes_max', type=int, default=0)
g.add_argument('--almacenamiento-mb', dest='almacenamiento_mb', type=int, default=0)
g.add_argument('--json', action='store_true')
g.set_defaults(func=cmd_generar)
v = sub.add_parser('verificar', help='verifica una clave de licencia')
v.add_argument('--clave', required=True)
v.add_argument('--json', action='store_true')
v.set_defaults(func=cmd_verificar)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
sys.exit(main())