Files
SKEEN-Proyecto/odoo-addons/skeen_plataforma/models/api_key.py

169 lines
5.8 KiB
Python

# -*- 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 '',
})