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:
69
docs/producto/api-terceros.md
Normal file
69
docs/producto/api-terceros.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# API de terceros gestionable (claves de API)
|
||||
|
||||
La API pública `/skeen/api/v1/*` (consumida por Hermes) se autentica con el
|
||||
método `auth='api_key'`, implementado en
|
||||
`skeen_plataforma/models/ir_http.py`. Acepta dos tipos de credencial:
|
||||
|
||||
1. **Clave legacy de Hermes** — `ir.config_parameter 'skeen.api.legacy_key'`,
|
||||
sembrada con el valor histórico `skeen_api_key_2026`. Tiene todos los
|
||||
alcances (compatibilidad total).
|
||||
2. **Claves del modelo `skeen.api.key`** — con alcances (*scopes*), vencimiento
|
||||
y revocación.
|
||||
|
||||
## Formato de autenticación
|
||||
|
||||
```
|
||||
Authorization: Bearer <clave>
|
||||
X-API-Key: <clave>
|
||||
?api_key=<clave> (query/form)
|
||||
```
|
||||
|
||||
## Modelo `skeen.api.key`
|
||||
|
||||
| Campo | Descripción |
|
||||
| --- | --- |
|
||||
| `name` | Nombre de la clave |
|
||||
| `prefijo` | Prefijo visible (los primeros 14 caracteres) |
|
||||
| `hash` | SHA-256 de la clave (nunca se guarda en claro) |
|
||||
| `activo` | Habilitada |
|
||||
| `scopes` | Lista JSON de alcances; vacío = todos |
|
||||
| `vence` | Fecha de vencimiento (opcional) |
|
||||
| `ultimo_uso`, `usos` | Uso registrado en cada autenticación |
|
||||
| `notas`, `creado_por_login` | Metadatos |
|
||||
| `revocada_at`, `revocada_por` | Revocación |
|
||||
|
||||
La clave **se muestra una sola vez** al crearla (solo se guarda el hash; la
|
||||
comparación es en tiempo constante).
|
||||
|
||||
## Alcances por endpoint
|
||||
|
||||
| Endpoint | Alcance |
|
||||
| --- | --- |
|
||||
| `POST /skeen/api/v1/available_slots` | `citas.read` |
|
||||
| `POST /skeen/api/v1/create_appointment` | `citas.write` |
|
||||
| `POST /skeen/api/v1/cancel_appointment` | `citas.write` |
|
||||
| `POST /skeen/api/v1/patient_balance` | `pacientes.read` |
|
||||
| `POST /skeen/api/v1/wallet_redeem` | `monedero.write` |
|
||||
| `GET/POST /skeen/api/v1/services` | `servicios.read` |
|
||||
|
||||
Una clave sin el alcance recibe **HTTP 403** con mensaje claro. La clave legacy
|
||||
no tiene restricción de alcance.
|
||||
|
||||
Las respuestas son **JSON plano** (no JSON-RPC): códigos 200 correctos,
|
||||
`401/403` para clave ausente/inválida o sin alcance.
|
||||
|
||||
## Endpoints de gestión
|
||||
|
||||
| Método | Ruta | Acceso |
|
||||
| --- | --- | --- |
|
||||
| GET | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) |
|
||||
| POST | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) |
|
||||
| PUT | `/skeen/frontend/v1/api_keys/<id>` | permiso `api.claves` (admin) |
|
||||
| POST | `/skeen/frontend/v1/api_keys/<id>/revocar` | permiso `api.claves` (admin) |
|
||||
|
||||
## UI
|
||||
|
||||
Configuración → pestaña **API** (admin): lista de claves (nombre, prefijo,
|
||||
alcances, último uso, usos, estado), botón "nueva clave" con modal que muestra
|
||||
la clave una vez y botón de copiar, editar y revocar; y documentación breve de
|
||||
los endpoints y del formato de autenticación.
|
||||
118
docs/producto/licencias.md
Normal file
118
docs/producto/licencias.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Licenciamiento por plan
|
||||
|
||||
Producto comercial: cada instancia white-label puede llevar una **licencia
|
||||
firmada** que decide qué módulos incluye y sus límites de uso. Sin licencia
|
||||
aplicada (el default) el sistema se comporta **exactamente como hoy**.
|
||||
|
||||
## Bloque `licencia` de clinic-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"plan": "custom",
|
||||
"modo": "aviso",
|
||||
"cliente": "",
|
||||
"vence": "",
|
||||
"clave": "",
|
||||
"modulos": [],
|
||||
"limites": {
|
||||
"usuarios_max": 0,
|
||||
"sucursales_max": 0,
|
||||
"citas_mes_max": 0,
|
||||
"almacenamiento_mb": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `plan`: `basico | profesional | enterprise | custom`.
|
||||
* `modo`: `aviso` (default; **solo advierte**) o `bloqueo` (aplica de verdad).
|
||||
* `limites`: `0` = **sin límite** (default → nada cambia).
|
||||
* `modulos`: lista de módulos incluidos; **vacío = todos**.
|
||||
* `clave`: la clave firmada por el vendedor. Vacía = sin licencia aplicada.
|
||||
|
||||
La licencia **nunca** bloquea lo indispensable: login, agenda, pacientes, POS,
|
||||
inventario, reportes y configuración siempre están disponibles. Solo aplica a
|
||||
los *módulos gateables* y a los límites indicados.
|
||||
|
||||
## Tabla de planes
|
||||
|
||||
| Plan | Módulos incluidos |
|
||||
| --- | --- |
|
||||
| `basico` | `agenda`, `pacientes`, `pos`, `inventario`, `reportes` (sin whatsapp, notificaciones, reservas, recursos, membresías, compras, comisiones, formularios) |
|
||||
| `profesional` | Básico **+** `whatsapp`, `notificaciones`, `reservas_online`, `recursos` |
|
||||
| `enterprise` | Todos los módulos (los 5 base + los 8 gateables) |
|
||||
| `custom` | Lo que diga la clave (si no lista módulos, todos) |
|
||||
|
||||
Módulos *gateables* (controlables por licencia): `whatsapp`, `notificaciones`,
|
||||
`reservas_online`, `recursos`, `membresias`, `compras`, `comisiones`,
|
||||
`formularios`.
|
||||
|
||||
## Aplicación con `modo=bloqueo`
|
||||
|
||||
* **Módulos no incluidos**: sus secciones desaparecen del menú, sus endpoints
|
||||
responden **403** con un mensaje claro (`Módulo no incluido en tu plan`) y su
|
||||
bloque `activo` de clinic-settings se considera apagado.
|
||||
* **Límites**: se impide crear más allá de `usuarios_max`, `sucursales_max`,
|
||||
`citas_mes_max` (citas del mes en curso) y `almacenamiento_mb` (suma de
|
||||
adjuntos). El error indica el límite y cómo ampliarlo.
|
||||
* Con `modo=aviso` nada se bloquea; los excesos solo quedan advertidos.
|
||||
|
||||
Las tareas internas (cron, migraciones) pueden saltarse los límites con el
|
||||
contexto `skeen_licencia_skip=True`.
|
||||
|
||||
## Formato de la clave de licencia
|
||||
|
||||
Texto en una línea:
|
||||
|
||||
```
|
||||
SKN1.<base64url(payload_json)>.<base64url(hmac_sha256(payload_b64, vendor_key))>
|
||||
```
|
||||
|
||||
* `payload_json` = JSON con claves ordenadas y separadores mínimos:
|
||||
`cliente`, `plan`, `vence`, `emitida`, `modulos`, `limites`.
|
||||
* Firmado con **HMAC-SHA256** y la clave del vendedor.
|
||||
|
||||
### Clave del vendedor
|
||||
|
||||
Debe estar presente en la instancia para poder validar (HMAC es simétrico):
|
||||
|
||||
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`)
|
||||
|
||||
> **Ruta del secreto:** `/root/.skeen-licencias/vendor.key` (fuera del repo, 600).
|
||||
> Nota de seguridad: al ser HMAC simétrico, quien tenga la clave del vendedor
|
||||
> puede forjar licencias; mantenerla fuera de las instancias de cliente en un
|
||||
> despliegue real (aquí vive en un VPS de desarrollo compartido).
|
||||
|
||||
### Herramienta del vendedor
|
||||
|
||||
`/root/skeen-proyecto/infra/licencias/generar_licencia.py` (CLI, Python stdlib):
|
||||
|
||||
```bash
|
||||
generar_licencia.py generar --cliente "Spa Bella" --plan profesional --vence 2027-12-31
|
||||
generar_licencia.py generar --cliente "Spa Bella" --plan custom --vence 2027-12-31 \
|
||||
--modulos agenda,pacientes,pos,inventario,reportes,compras \
|
||||
--usuarios-max 10 --sucursales-max 2 --citas-mes-max 500 --almacenamiento-mb 4096
|
||||
generar_licencia.py verificar --clave "SKN1...."
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Método | Ruta | Acceso | Descripción |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/skeen/frontend/v1/licencia` | admin/gerencia | Estado completo + uso vs límites |
|
||||
| PUT | `/skeen/frontend/v1/licencia` | admin | Aplicar/actualizar/quitar licencia |
|
||||
| POST | `/skeen/frontend/v1/licencia/validar` | admin/gerencia | Probar una clave sin aplicarla |
|
||||
|
||||
`PUT` con `{"clave": "SKN1..."}` verifica firma y vigencia; si es inválida o
|
||||
vencida responde **400** y **no sobrescribe** la licencia previa. `{"clave": ""}`
|
||||
quita la licencia y vuelve a habilitarlo todo. `{"modo": "bloqueo"}` cambia el
|
||||
modo sin re-firmar.
|
||||
|
||||
## UI
|
||||
|
||||
Configuración → pestaña **Licencia** (admin; gerencia también ve el estado):
|
||||
plan y cliente, vencimiento con días restantes, campo para pegar la clave,
|
||||
tabla de uso vs límites con semáforo, tabla de módulos incluidos/no incluidos,
|
||||
avisos de vencimiento (≤ 15 días) y, si no hay licencia, el mensaje
|
||||
*"sin licencia aplicada — todas las funciones habilitadas"*.
|
||||
112
docs/producto/webhooks.md
Normal file
112
docs/producto/webhooks.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Webhooks salientes
|
||||
|
||||
Notificación de eventos de la clínica hacia sistemas externos, con patrón
|
||||
**outbox + cron + reintentos + bitácora**. Sin webhooks activos suscritos no se
|
||||
crea ninguna fila ni se envía nada (cero impacto).
|
||||
|
||||
## Modelos
|
||||
|
||||
### `skeen.webhook`
|
||||
|
||||
| Campo | Descripción |
|
||||
| --- | --- |
|
||||
| `name`, `url` | Nombre y URL destino |
|
||||
| `eventos` | Claves separadas por coma; vacío = todos |
|
||||
| `activo` | Habilitado |
|
||||
| `secreto` | Con este secreto se firma cada entrega |
|
||||
| `headers` | Cabeceras extra (JSON, opcional) |
|
||||
| `notas`, `creado_por_login` | Metadatos |
|
||||
| `ultima_entrega_at` | Última entrega correcta |
|
||||
| `fallos_consecutivos` | Se desactiva tras N fallos (default 20, configurable en `skeen.webhooks.fallos_max`) |
|
||||
|
||||
### `skeen.webhook.evento` (outbox)
|
||||
|
||||
`webhook_id`, `evento`, `payload` (JSON), `estado`
|
||||
(`pendiente|enviado|fallido|descartado`), `intentos`, `proximo_intento`,
|
||||
`respuesta_codigo`, `respuesta_texto`, `enviado_at`, `clave_unica`
|
||||
(idempotencia por evento+objeto).
|
||||
|
||||
## Catálogo de eventos
|
||||
|
||||
| Evento | Cuándo se emite |
|
||||
| --- | --- |
|
||||
| `cita.creada` | Al crear una cita |
|
||||
| `cita.confirmada` | `state` → `confirmed` |
|
||||
| `cita.cancelada` | `state` → `cancelled` |
|
||||
| `cita.no_show` | `state` → `no_show` |
|
||||
| `cita.completada` | `state` → `done` |
|
||||
| `venta.pagada` | `state` → `paid` |
|
||||
| `venta.cancelada` | `state` → `cancelled` o `refunded` |
|
||||
| `pago.registrado` | Al crear un `skeen.pago` |
|
||||
| `paciente.creado` | Al crear un `res.partner` con `is_patient` |
|
||||
| `membresia.renovada` | `action_renovar` |
|
||||
| `formulario.llenado` | `estado` → `llenado` |
|
||||
| `compra.recibida` | `estado` → `recibida`/`recibida_parcial` |
|
||||
|
||||
### Ejemplo de payload (`cita.creada`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"reference": "CITA-0123",
|
||||
"state": "pending",
|
||||
"date": "2026-10-20",
|
||||
"patient_id": 55,
|
||||
"patient": "María López",
|
||||
"service_id": 7,
|
||||
"servicio": "Limpieza facial",
|
||||
"doctor": "Dra. Pérez"
|
||||
}
|
||||
```
|
||||
|
||||
## Entrega
|
||||
|
||||
Cron cada 5 minutos (`skeen.webhook.service._cron_tick`) que toma los pendientes
|
||||
vencidos y hace `POST` JSON con:
|
||||
|
||||
| Cabecera | Valor |
|
||||
| --- | --- |
|
||||
| `X-Skeen-Event` | Clave del evento |
|
||||
| `X-Skeen-Delivery` | UUID de la entrega |
|
||||
| `X-Skeen-Signature` | `sha256=<HMAC-SHA256 del cuerpo con el secreto>` |
|
||||
|
||||
* Timeouts cortos (5 s conexión / 15 s lectura).
|
||||
* **Reintentos con backoff**: 1, 5, 30 min, 2 h, 6 h (tope 5 intentos); al
|
||||
agotarlos el evento queda `fallido`.
|
||||
* Tras N fallos consecutivos (default 20) el webhook se desactiva.
|
||||
|
||||
### Verificar la firma (Python)
|
||||
|
||||
```python
|
||||
import hashlib, hmac
|
||||
firma = request.headers['X-Skeen-Signature'] # 'sha256=...'
|
||||
esperada = 'sha256=' + hmac.new(
|
||||
SECRETO.encode(), request.body, hashlib.sha256).hexdigest()
|
||||
assert hmac.compare_digest(firma, esperada)
|
||||
```
|
||||
|
||||
### Verificar la firma (Node.js)
|
||||
|
||||
```js
|
||||
const crypto = require('crypto');
|
||||
const esperada = 'sha256=' + crypto
|
||||
.createHmac('sha256', SECRETO)
|
||||
.update(req.rawBody)
|
||||
.digest('hex');
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Método | Ruta | Acceso |
|
||||
| --- | --- | --- |
|
||||
| GET/POST | `/skeen/frontend/v1/webhooks` | permiso `webhooks.editar` (admin/gerencia) |
|
||||
| PUT/DELETE | `/skeen/frontend/v1/webhooks/<id>` | idem |
|
||||
| GET | `/skeen/frontend/v1/webhooks/<id>/eventos` | idem (filtra por `estado`, `evento`, `limit`) |
|
||||
| POST | `/skeen/frontend/v1/webhooks/<id>/probar` | idem (evento de prueba) |
|
||||
| POST | `/skeen/frontend/v1/webhook_eventos/<id>/reintentar` | idem |
|
||||
|
||||
## UI
|
||||
|
||||
Configuración → pestaña **Webhooks** (admin/gerencia): CRUD, selector de
|
||||
eventos, secreto (generado o propio), visor del payload de ejemplo, log de
|
||||
entregas con estado/código/fecha, reintento manual y "probar".
|
||||
Reference in New Issue
Block a user