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:
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal file
168
odoo-addons/skeen_plataforma/models/api_key.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Claves de API de terceros (`skeen.api.key`).
|
||||
|
||||
La clave se muestra UNA sola vez al crearla: solo se guarda su hash SHA-256 y
|
||||
un prefijo visible para identificarla. La comparación es en tiempo constante.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
# Alcances disponibles (vacío = todos).
|
||||
SCOPES = [
|
||||
('citas.read', 'Consultar citas y disponibilidad'),
|
||||
('citas.write', 'Crear y cancelar citas'),
|
||||
('pacientes.read', 'Consultar pacientes y saldos'),
|
||||
('servicios.read', 'Consultar el catálogo de servicios'),
|
||||
('monedero.write', 'Redimir puntos del monedero'),
|
||||
]
|
||||
SCOPE_KEYS = tuple(k for k, _l in SCOPES)
|
||||
SCOPE_LABEL = dict(SCOPES)
|
||||
|
||||
PREFIJO_LARGO = 14 # caracteres visibles de la clave
|
||||
|
||||
|
||||
def hash_clave(raw):
|
||||
return hashlib.sha256((raw or '').encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class SkeenApiKey(models.Model):
|
||||
_name = 'skeen.api.key'
|
||||
_description = 'Clave de API de terceros'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Nombre', required=True)
|
||||
prefijo = fields.Char(string='Prefijo visible', required=True, index=True)
|
||||
hash = fields.Char(string='Hash', required=True)
|
||||
activo = fields.Boolean(string='Activa', default=True)
|
||||
scopes = fields.Char(string='Alcances', default='[]',
|
||||
help='Lista JSON de alcances. Vacío = todos.')
|
||||
vence = fields.Date(string='Vence')
|
||||
ultimo_uso = fields.Datetime(string='Último uso')
|
||||
usos = fields.Integer(string='Usos', default=0)
|
||||
notas = fields.Text(string='Notas')
|
||||
creado_por_login = fields.Char(string='Creada por')
|
||||
revocada_at = fields.Datetime(string='Revocada el')
|
||||
revocada_por = fields.Char(string='Revocada por')
|
||||
|
||||
_sql_constraints = [
|
||||
('hash_uniq', 'unique(hash)', 'Ya existe una clave de API con ese hash.'),
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def generar(self, vals, login=''):
|
||||
"""Crea una clave y devuelve (record, clave_en_claro).
|
||||
|
||||
La clave en claro solo se devuelve aquí; no se persiste.
|
||||
"""
|
||||
raw = 'sk_' + secrets.token_urlsafe(32)
|
||||
record = self.create({
|
||||
'name': vals.get('name') or 'Clave de API',
|
||||
'prefijo': raw[:PREFIJO_LARGO],
|
||||
'hash': hash_clave(raw),
|
||||
'scopes': json.dumps(self._normalizar_scopes(vals.get('scopes') or [])),
|
||||
'vence': vals.get('vence') or False,
|
||||
'notas': vals.get('notas') or '',
|
||||
'creado_por_login': login or '',
|
||||
'activo': True,
|
||||
})
|
||||
return record, raw
|
||||
|
||||
@api.model
|
||||
def _normalizar_scopes(self, scopes):
|
||||
if not isinstance(scopes, (list, tuple)):
|
||||
return []
|
||||
limpios = []
|
||||
for s in scopes:
|
||||
s = str(s or '').strip()
|
||||
if s and s in SCOPE_KEYS and s not in limpios:
|
||||
limpios.append(s)
|
||||
return limpios
|
||||
|
||||
def scopes_list(self):
|
||||
try:
|
||||
val = json.loads(self.scopes or '[]')
|
||||
except (ValueError, TypeError):
|
||||
val = []
|
||||
return val if isinstance(val, list) else []
|
||||
|
||||
def scopes_label(self):
|
||||
"""Texto para la UI: 'todos' o la lista de etiquetas."""
|
||||
scopes = self.scopes_list()
|
||||
if not scopes:
|
||||
return 'Todos'
|
||||
return ', '.join(SCOPE_LABEL.get(s, s) for s in scopes)
|
||||
|
||||
def permite(self, scope):
|
||||
"""True si la clave concede el alcance (vacío = todos). Nunca si inactiva."""
|
||||
if not self.activo:
|
||||
return False
|
||||
if self.vence and self.vence < fields.Date.today():
|
||||
return False
|
||||
scopes = self.scopes_list()
|
||||
if not scopes:
|
||||
return True
|
||||
return scope in scopes
|
||||
|
||||
def estado(self):
|
||||
if self.revocada_at or not self.activo:
|
||||
return 'revocada'
|
||||
if self.vence and self.vence < fields.Date.today():
|
||||
return 'vencida'
|
||||
return 'activa'
|
||||
|
||||
def to_public_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'prefijo': self.prefijo,
|
||||
'activo': self.activo,
|
||||
'scopes': self.scopes_list(),
|
||||
'scopes_label': self.scopes_label(),
|
||||
'vence': self.vence.strftime('%Y-%m-%d') if self.vence else '',
|
||||
'ultimo_uso': self.ultimo_uso.strftime('%Y-%m-%d %H:%M') if self.ultimo_uso else '',
|
||||
'usos': self.usos,
|
||||
'notas': self.notas or '',
|
||||
'creado_por_login': self.creado_por_login or '',
|
||||
'revocada_at': self.revocada_at.strftime('%Y-%m-%d %H:%M') if self.revocada_at else '',
|
||||
'revocada_por': self.revocada_por or '',
|
||||
'estado': self.estado(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _autenticar(self, raw):
|
||||
"""Devuelve la clave activa que corresponde a `raw` (o recordset vacío)."""
|
||||
raw = (raw or '').strip()
|
||||
if not raw:
|
||||
return self.browse()
|
||||
candidatas = self.search([
|
||||
('prefijo', '=', raw[:PREFIJO_LARGO]), ('activo', '=', True),
|
||||
])
|
||||
digest = hash_clave(raw)
|
||||
for key in candidatas:
|
||||
if hmac.compare_digest(key.hash or '', digest):
|
||||
return key
|
||||
# Respaldo: por si el prefijo almacenado difiere, comparar por hash.
|
||||
return self.search([('hash', '=', digest), ('activo', '=', True)], limit=1)
|
||||
|
||||
def _registrar_uso(self):
|
||||
if not self:
|
||||
return
|
||||
self.sudo().write({
|
||||
'ultimo_uso': fields.Datetime.now(),
|
||||
'usos': self.usos + 1,
|
||||
})
|
||||
|
||||
def action_revocar(self, login=''):
|
||||
self.write({
|
||||
'activo': False,
|
||||
'revocada_at': fields.Datetime.now(),
|
||||
'revocada_por': login or '',
|
||||
})
|
||||
Reference in New Issue
Block a user