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