Files
SKEEN-Proyecto/odoo-addons/skeen_whatsapp/controllers/auth.py
Consultoría Alcaraz Salazar 169864958d permisos de agenda: selector de acciones por usuario (ver, agendar, editar, eliminar, visitas)
- roles.py: AGENDA_PERMS, AGENDA_PERM_KEYS, WRITE_RANK_MIN, agenda_defaults_for,
  resolve_agenda_perms, AGENDA_STATUS_PERM, AGENDA_STATE_PERM, _agenda_status_perm
- frontend_user.py: campo agenda_perms, _agenda_perms_list(), to_public_dict()
  expone agenda_perms y agenda_perms_custom
- auth.py: require_agenda(perm) decorador + agenda_perm_error()
- frontend.py: @require_agenda aplicado a 22 endpoints + branch en
  update_appointment_status por accion
- auth_users_update: whitelist acepta agenda_perms
- Odoo upgrade: columna skeen_frontend_user.agenda_perms
- Frontend: types AgendaPerm, AGENDA_PERM_ITEMS, canAgenda() en auth.tsx
- UsuariosPanel: checkboxes de permisos de agenda en modal de permisos
- Agenda.tsx: gates por permiso en crear/editar/eliminar/visitas/dragdrop/bloqueos
- EstadoChecklist/BloqueosModal/ListaEspera: hide actions without permission

Verificacion API: 18/18 tests OK (admin hereda, recorte=403, lect=403, restore=null)
2026-09-30 09:46:51 +00:00

228 lines
7.7 KiB
Python

# -*- coding: utf-8 -*-
"""Autenticación ligera (token HMAC) para el frontend React de SKEEN.
Token = base64url(payload_json) + "." + firma HMAC-SHA256, sin tabla de sesiones.
El secreto vive en ir.config_parameter 'skeen.frontend.secret' (se genera al primer uso).
"""
import base64
import functools
import hashlib
import hmac
import json
import secrets
import time
from odoo.http import request, Response
from ..roles import ROLE_RANKS, get_menu_roles, AGENDA_PERMS, resolve_agenda_perms
TTL_SECONDS = 60 * 60 * 12 # 12 horas
SECRET_KEY = 'skeen.frontend.secret'
RANK = ROLE_RANKS
# Módulos de dinero globales vetados para el rol 'medico' por API: su menú ya
# los oculta (MENU_ROLES) y aquí se cierra la vía directa con token. Su propio
# reporte (/my/sales) no pasa por esta lista.
MEDICO_BLOCKED_PREFIXES = (
'/skeen/frontend/v1/reports/',
'/skeen/frontend/v1/sales',
'/skeen/frontend/v1/payments',
'/skeen/frontend/v1/wallets',
'/skeen/frontend/v1/pos/',
'/skeen/frontend/v1/cash_closings',
'/skeen/frontend/v1/doctors/',
)
def _medico_reporte_permitido(env, path):
"""True si la matriz de menus concede al medico ESA pestana de reportes.
El veto de 'medico' sobre /reports/ es por diseno (no ve dinero), pero la
matriz permite habilitar pestañas puntuales (/reportes?tab=horas-agenda).
Sin este puente el menu mostraria la opcion y la API la rechazaria con 403.
Las demas rutas vetadas (sales, payments, wallets...) siguen cerradas.
"""
prefijo = '/skeen/frontend/v1/reports/'
if not path.startswith(prefijo):
return False
tab = path[len(prefijo):].strip('/')
if not tab or '/' in tab:
return False
matriz = get_menu_roles(env)
if 'medico' in (matriz.get('/reportes') or []):
return True
return 'medico' in (matriz.get('/reportes?tab=%s' % tab) or [])
def _cors_headers():
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers=_cors_headers(),
)
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
def _b64url_decode(s: str) -> bytes:
pad = '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _get_secret(env) -> str:
ICP = env['ir.config_parameter'].sudo()
secret = ICP.get_param(SECRET_KEY)
if not secret:
secret = secrets.token_urlsafe(48)
ICP.set_param(SECRET_KEY, secret)
return secret
def sign_token(env, user) -> str:
payload = {
'uid': user.id,
'login': user.login,
'role': user.role,
'exp': int(time.time()) + TTL_SECONDS,
}
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
sig = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
return f'{body}.{sig}'
def verify_token(env, token: str):
if not token or '.' not in token:
return None
body, sig = token.rsplit('.', 1)
expected = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return None
try:
payload = json.loads(_b64url_decode(body).decode('utf-8'))
except Exception:
return None
if int(payload.get('exp', 0)) < int(time.time()):
return None
return payload
def current_user(env):
"""Devuelve el usuario autenticado (record) o None."""
auth = request.httprequest.headers.get('Authorization', '') or ''
if not auth.lower().startswith('bearer '):
return None
token = auth[7:].strip()
payload = verify_token(env, token)
if not payload:
return None
user = env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
if not user.exists() or not user.active:
return None
return user
def forbid_roles(*roles):
"""Decorator: niega el acceso a una lista explícita de roles.
Se usa para cerrar operaciones de dinero a roles con rango de escritura
que no deben tocarlas (p. ej. marketing, que comparte rango con recepción
para poder agendar, pero no debe cobrar).
"""
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
user = current_user(request.env)
if user and user.role in roles:
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
return func(self, *args, **kw)
return wrapper
return decorator
def require_role(min_role='lectura'):
"""Decorator: exige token válido y un rol con rango >= min_role.
Deja pasar OPTIONS (preflight CORS) sin autenticación.
"""
required = RANK.get(min_role, 10)
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
user = current_user(request.env)
if not user:
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
if user.role_rank() < required:
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
if user.role == 'medico' and any(
request.httprequest.path == p or request.httprequest.path.startswith(
p if p.endswith('/') else p + '/')
for p in MEDICO_BLOCKED_PREFIXES
) and not _medico_reporte_permitido(request.env, request.httprequest.path):
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
request.frontend_user = user
return func(self, *args, **kw)
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Permisos de ACCION dentro de la Agenda (selector por usuario).
#
# require_role sigue siendo el guard de rango: el token no lleva permisos,
# asi que no hay listas cacheadas que se puedan desincronizar. Este decorador
# REFINA dentro de la agenda y solo puede QUITAR acceso, nunca darlo por
# encima del rango del rol.
# ---------------------------------------------------------------------------
AGENDA_PERM_LABELS = dict(AGENDA_PERMS)
def agenda_perm_error(user, perm):
"""Response 403 si la accion no esta permitida; None si si lo esta."""
if user is None or perm not in resolve_agenda_perms(user):
label = AGENDA_PERM_LABELS.get(perm, perm)
return _json({'status': 'error', 'message': 'Sin permiso: %s' % label}, 403)
return None
def require_agenda(perm):
"""Decorator: exige token valido y la accion de agenda indicada.
Se apila por DEBAJO de require_role (decorador interno), de modo que el
control de rango del rol ya corrió y solo falta refinar la acción concreta.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
user = current_user(request.env)
if not user:
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
denied = agenda_perm_error(user, perm)
if denied is not None:
return denied
request.frontend_user = user
return func(self, *args, **kw)
return wrapper
return decorator