Jose Salazar Claude Opus 5 commited on
Commit
1339cdc
·
1 Parent(s): b90d0d2

Aislar por clínica los resultados de analizador

Browse files

ARCHITECTURE_REVIEW §2.1 (+ la parte de identidad de §2.9). Era el hallazgo más
grave de la revisión: `lab/almacen.py` era un dict global sin dueño y
`routers/lab.py` no filtraba nada, así que CUALQUIER sesión autenticada podía
leer la muestra de cualquier clínica, y `/api/lab/pendientes` las enumeraba todas
—sin necesidad siquiera de adivinar IDs—. Cada `muestra_id` abre el panel completo
de analitos más las pistas de paciente (nombre de la mascota, raza, sexo).

IDENTIDAD DE DISPOSITIVO. `lab_api_keys` pasa de lista de secretos anónimos a
`clinica:clave`. `verificar_dispositivo` devuelve el tenant dueño en vez de None,
y el recorrido sigue siendo en tiempo constante sobre TODAS las claves (un lookup
por diccionario sobre un secreto filtra por tiempo). Revocar = quitar la clave y
reiniciar.

DE DÓNDE SALE EL TENANT. Del servidor, siempre: de la API key en la ingesta y de
la cookie FIRMADA en la lectura. Nunca del cuerpo de la petición —si el puente
pudiera declarar su clínica, mentir en un campo bastaría para escribir en la de
otro— ni de un parámetro de consulta. Hay prueba de las dos cosas.

USUARIOS. Columna `tenant` (migración 3, estrenando el mecanismo del commit
anterior) asignada desde la allowlist (`email=clinica`), no desde el formulario.

COMPATIBILIDAD. Sin tenants declarados todo cae en "principal": dispositivos sin
prefijo, cuentas sin sufijo y las sesiones ya emitidas, que no llevan el campo en
la cookie y durarían hasta 8h. Un despliegue de una sola clínica —el caso actual—
no nota el cambio, y el aislamiento aparece en cuanto se declaran clínicas.

Una muestra de otra clínica devuelve 404, no 403: que exista tampoco es
información que se dé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

CLAUDE.md CHANGED
@@ -119,12 +119,15 @@ Dos defectos que se cerraron y **no se vuelven a abrir sin sustituirlos por algo
119
  el alta se convierte en un oráculo de qué cuentas hay (403 siempre, nunca 409, fuera de la
120
  lista). Es una **lista de emails y no un booleano** porque `instance/` es efímero: sin ella,
121
  el primer reinicio deja la instancia sin cuentas y sin forma de crear ninguna.
122
- - **`GET /api/lab/pendientes` está apagado** (`lab_pendientes_habilitado=False` 404). Enumera
123
- las muestras de todas las clínicas y cada `muestra_id` abre el panel completo más las pistas
124
- de paciente. **Apagarlo no cierra el agujero y no hay que documentarlo como si lo hiciera**:
125
- el `muestra_id` lo pone el analizador y suele ser correlativo, así que `/api/lab/resultados`
126
- sigue siendo enumerable. Lo que elimina es el volcado en una petición. El cierre real es atar
127
- cada resultado a un tenant y filtrar por sesión (ARCHITECTURE_REVIEW §2.1).
 
 
 
128
 
129
  Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
130
  `alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
 
119
  el alta se convierte en un oráculo de qué cuentas hay (403 siempre, nunca 409, fuera de la
120
  lista). Es una **lista de emails y no un booleano** porque `instance/` es efímero: sin ella,
121
  el primer reinicio deja la instancia sin cuentas y sin forma de crear ninguna.
122
+ - **Los resultados de analizador están segmentados por CLÍNICA (tenant).** El tenant lo pone
123
+ siempre el servidor: de la API key del dispositivo en la ingesta (`clinica:clave`) y de la
124
+ cookie firmada en la lectura. **Nunca del cuerpo ni de un parámetro** si el puente pudiera
125
+ declarar su clínica, mentir en un campo bastaría para escribir en la de otro. Una muestra de
126
+ otra clínica devuelve 404, no 403. Sin tenants declarados todo cae en `principal`, así que un
127
+ despliegue de una sola clínica no nota nada.
128
+ - **`GET /api/lab/pendientes` sigue apagado por defecto** (`lab_pendientes_habilitado=False` →
129
+ 404). Ya no es un volcado global —sólo lista la clínica de la sesión—, pero dentro de ella
130
+ enumera todas las muestras, así que se enciende a propósito.
131
 
132
  Las pruebas describen el defecto CERRADO; las que sólo necesitan sesión piden el fixture
133
  `alta_abierta`. El fixture `_limitador_limpio` (autouse) vacía el contador de rate limiting
backend/.env.example CHANGED
@@ -18,7 +18,9 @@ MORPHOS_COOKIE_SECURE=false # true en prod (HTTPS)
18
  # IMPORTANTE en un despliegue: si dejas las dos vacías, NADIE puede darse de alta. Con
19
  # `instance/` efímero (HF Spaces) las cuentas desaparecen en cada reinicio, así que sin
20
  # allowlist la instancia se queda sin acceso posible. Pon aquí los emails aprobados.
21
- # MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@clinica.com
 
 
22
  MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
23
 
24
  # --- Base de datos de usuarios (SQLite fuera del webroot) ---
@@ -81,12 +83,17 @@ MORPHOS_RAG_MAX_POR_LIBRO=2
81
  # MORPHOS_RAG_SCORE_MINIMO=
82
 
83
  # --- Integración de analizadores de laboratorio ---
84
- # MORPHOS_LAB_API_KEYS=clave-del-puente-1,clave-del-puente-2
 
 
 
 
 
85
  # Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
86
- # UI. DESACTIVADA por defecto: el almacén no está segmentado por clínica, así que enumera las
87
- # muestras de TODAS y cada ID abre el panel completo de analitos. Enciéndela sólo donde las
88
- # sesiones son de una única clínica. Apagarla NO cierra el agujero (el muestra_id lo pone el
89
- # analizador y suele ser correlativo): el cierre real es filtrar por tenant.
90
  MORPHOS_LAB_PENDIENTES_HABILITADO=false
91
 
92
  # --- Proxy inverso ---
 
18
  # IMPORTANTE en un despliegue: si dejas las dos vacías, NADIE puede darse de alta. Con
19
  # `instance/` efímero (HF Spaces) las cuentas desaparecen en cada reinicio, así que sin
20
  # allowlist la instancia se queda sin acceso posible. Pon aquí los emails aprobados.
21
+ # Formato: `email` o `email=clinica`. La clínica (tenant) decide qué resultados de analizador
22
+ # ve esa cuenta; sin sufijo, la de por defecto ("principal").
23
+ # MORPHOS_REGISTRO_ALLOWLIST=vet1@clinica.com,vet2@otra.com=clinica-b
24
  MORPHOS_REGISTRO_ABIERTO=false # true SÓLO en desarrollo local
25
 
26
  # --- Base de datos de usuarios (SQLite fuera del webroot) ---
 
83
  # MORPHOS_RAG_SCORE_MINIMO=
84
 
85
  # --- Integración de analizadores de laboratorio ---
86
+ # Formato: `clave` o `clinica:clave`. La clínica es la DUEÑA de lo que ingiera ese dispositivo,
87
+ # y sólo las sesiones de esa misma clínica pueden leerlo. Sin prefijo, la de por defecto
88
+ # ("principal"), que es lo que quiere un despliegue de una sola clínica.
89
+ # El puente NO puede declarar su clínica en el cuerpo: sale de la clave, en el servidor.
90
+ # Revocar un dispositivo = quitar su clave de aquí y reiniciar.
91
+ # MORPHOS_LAB_API_KEYS=clinica-a:clave-del-puente-1,clinica-b:clave-del-puente-2
92
  # Cola de muestras recibidas (GET /api/lab/pendientes) y el botón "Resultados pendientes" de la
93
+ # UI. Desde el filtrado por clínica sólo enumera las muestras de la clínica de la sesión, así
94
+ # que ya no es un volcado global. Sigue DESACTIVADA por defecto porque dentro de una misma
95
+ # clínica lista todas las muestras de todos sus pacientes, que es más de lo que necesita la
96
+ # mayoría de flujos; enciéndela si quieres el botón.
97
  MORPHOS_LAB_PENDIENTES_HABILITADO=false
98
 
99
  # --- Proxy inverso ---
backend/app/config.py CHANGED
@@ -7,6 +7,7 @@ lo necesario para una función concreta.
7
 
8
  from __future__ import annotations
9
 
 
10
  import logging
11
  import os
12
  from functools import lru_cache
@@ -24,6 +25,11 @@ RAIZ_REPO = Path(__file__).resolve().parents[2]
24
  # Punto de montaje del almacenamiento persistente en HF Spaces.
25
  VOLUMEN_PERSISTENTE = Path("/data")
26
 
 
 
 
 
 
27
 
28
  def _ruta_db_por_defecto() -> Path:
29
  """Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.
@@ -344,9 +350,19 @@ class Configuracion(BaseSettings):
344
  permitidos[nombre] = modo.strip().lower() == "prosa"
345
  return permitidos
346
 
 
 
 
 
 
 
 
 
 
 
347
  def emails_registro_permitidos(self) -> set[str]:
348
  """Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
349
- return {e.strip().lower() for e in self.registro_allowlist if e.strip()}
350
 
351
  def registro_permitido(self, email: str) -> bool:
352
  """Si este email puede darse de alta."""
@@ -354,6 +370,30 @@ class Configuracion(BaseSettings):
354
  return True
355
  return email.strip().lower() in self.emails_registro_permitidos()
356
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  def avisar_de_configuracion(self) -> None:
358
  """Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
359
  if self.registro_abierto:
 
7
 
8
  from __future__ import annotations
9
 
10
+ import hmac
11
  import logging
12
  import os
13
  from functools import lru_cache
 
25
  # Punto de montaje del almacenamiento persistente en HF Spaces.
26
  VOLUMEN_PERSISTENTE = Path("/data")
27
 
28
+ # Clínica a la que pertenecen los dispositivos y usuarios que no declaran otra. Un despliegue de
29
+ # una sola clínica —el caso normal— se queda entero aquí y no nota el cambio; el aislamiento
30
+ # aparece en cuanto se declaran tenants distintos.
31
+ TENANT_POR_DEFECTO = "principal"
32
+
33
 
34
  def _ruta_db_por_defecto() -> Path:
35
  """Volumen persistente si lo hay; si no, `instance/` (efímero) con aviso al arrancar.
 
350
  permitidos[nombre] = modo.strip().lower() == "prosa"
351
  return permitidos
352
 
353
+ def _allowlist_con_tenant(self) -> dict[str, str]:
354
+ """email → tenant. Formato `email` o `email=tenant`; sin sufijo, TENANT_POR_DEFECTO."""
355
+ mapa: dict[str, str] = {}
356
+ for entrada in self.registro_allowlist:
357
+ email, _, tenant = entrada.partition("=")
358
+ email = email.strip().lower()
359
+ if email:
360
+ mapa[email] = tenant.strip() or TENANT_POR_DEFECTO
361
+ return mapa
362
+
363
  def emails_registro_permitidos(self) -> set[str]:
364
  """Allowlist normalizada (minúsculas, sin espacios) para comparar con el email entrante."""
365
+ return set(self._allowlist_con_tenant())
366
 
367
  def registro_permitido(self, email: str) -> bool:
368
  """Si este email puede darse de alta."""
 
370
  return True
371
  return email.strip().lower() in self.emails_registro_permitidos()
372
 
373
+ def tenant_de_email(self, email: str) -> str:
374
+ """Clínica a la que pertenece un email al darse de alta.
375
+
376
+ Con el alta abierta (desarrollo) todo el mundo cae en el tenant por defecto: no hay
377
+ ninguna declaración de la que deducir otra cosa.
378
+ """
379
+ return self._allowlist_con_tenant().get(email.strip().lower(), TENANT_POR_DEFECTO)
380
+
381
+ def tenant_de_clave_dispositivo(self, token: str) -> str | None:
382
+ """Tenant dueño de esta API key de dispositivo, o None si no es válida.
383
+
384
+ Recorre TODAS las claves con `compare_digest` en vez de indexar un diccionario: un
385
+ lookup por hash sobre un secreto filtra por tiempo si coincide el prefijo, y esta
386
+ comparación es la única barrera de la ingesta.
387
+ """
388
+ encontrado: str | None = None
389
+ for entrada in self.lab_api_keys:
390
+ tenant, sep, clave = entrada.partition(":")
391
+ if not sep:
392
+ tenant, clave = TENANT_POR_DEFECTO, entrada
393
+ if hmac.compare_digest(token, clave.strip()):
394
+ encontrado = tenant.strip() or TENANT_POR_DEFECTO
395
+ return encontrado
396
+
397
  def avisar_de_configuracion(self) -> None:
398
  """Avisos de arranque que no justifican fallar, pero sí que se vean en el log."""
399
  if self.registro_abierto:
backend/app/db.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
  Diferencias de seguridad frente a la versión PHP:
4
  - La BD SQLite vive en instance/ FUERA del directorio servido (no es descargable).
5
- - Sin credenciales por defecto: si se configura MySQL, usuario/clave vienen de entorno.
6
  - Hash de contraseña con scrypt (stdlib), sal aleatoria por usuario.
7
  """
8
 
@@ -16,7 +16,7 @@ import sqlite3
16
  from collections.abc import Iterator
17
  from contextlib import contextmanager
18
 
19
- from .config import VOLUMEN_PERSISTENTE, obtener_config
20
 
21
  log = logging.getLogger("morphos.db")
22
 
@@ -26,10 +26,11 @@ log = logging.getLogger("morphos.db")
26
  # Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
27
  # resultante, y sólo se aplican los pasos por encima de la versión actual.
28
  #
29
- # Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo),
30
- # los pasos se añaden al final, y cada uno debe poder correr sobre una BD que ya lo tuviera
31
- # —de ahí los `IF NOT EXISTS`, porque las BD creadas antes de este mecanismo están en la
32
- # versión 0 con las tablas del paso 1 ya presentes.
 
33
  _MIGRACIONES: list[str] = [
34
  # 1 — esquema inicial (el que ya existía).
35
  """
@@ -60,6 +61,12 @@ CREATE TABLE IF NOT EXISTS resultados_lab (
60
  """
61
  CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
62
  ON intentos_login (email, ip, momento);
 
 
 
 
 
 
63
  """,
64
  ]
65
 
@@ -127,17 +134,19 @@ def verificar_password(password: str, almacenado: str) -> bool:
127
  def buscar_usuario(email: str) -> sqlite3.Row | None:
128
  with _conexion() as con:
129
  cur = con.execute(
130
- "SELECT id, nombre, apellido, email, password FROM usuarios WHERE email = ? LIMIT 1",
 
131
  (email,),
132
  )
133
  return cur.fetchone()
134
 
135
 
136
- def crear_usuario(nombre: str, apellido: str, email: str, password: str) -> None:
137
  with _conexion() as con:
138
  con.execute(
139
- "INSERT INTO usuarios (nombre, apellido, email, password) VALUES (?, ?, ?, ?)",
140
- (nombre, apellido, email, hash_password(password)),
 
141
  )
142
 
143
 
 
2
 
3
  Diferencias de seguridad frente a la versión PHP:
4
  - La BD SQLite vive en instance/ FUERA del directorio servido (no es descargable).
5
+ - Esquema versionado con `PRAGMA user_version` (ver `_MIGRACIONES`).
6
  - Hash de contraseña con scrypt (stdlib), sal aleatoria por usuario.
7
  """
8
 
 
16
  from collections.abc import Iterator
17
  from contextlib import contextmanager
18
 
19
+ from .config import TENANT_POR_DEFECTO, VOLUMEN_PERSISTENTE, obtener_config
20
 
21
  log = logging.getLogger("morphos.db")
22
 
 
26
  # Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
27
  # resultante, y sólo se aplican los pasos por encima de la versión actual.
28
  #
29
+ # Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo) y
30
+ # los pasos se añaden al final. La versión garantiza que cada paso corre UNA vez, así que no
31
+ # tienen por qué ser idempotentesel 3 es un ALTER TABLE, que no lo es—. Los `IF NOT EXISTS`
32
+ # del paso 1 son por otro motivo: las BD creadas antes de este mecanismo están en la versión 0
33
+ # con esas tablas ya presentes, y hay que poder ponerlas al día sin borrarlas.
34
  _MIGRACIONES: list[str] = [
35
  # 1 — esquema inicial (el que ya existía).
36
  """
 
61
  """
62
  CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
63
  ON intentos_login (email, ip, momento);
64
+ """,
65
+ # 3 — clínica dueña de cada cuenta. Los usuarios que ya existan quedan en el tenant por
66
+ # defecto, que es donde también caen los dispositivos sin clínica declarada: un despliegue
67
+ # de una sola clínica no nota el cambio.
68
+ f"""
69
+ ALTER TABLE usuarios ADD COLUMN tenant TEXT NOT NULL DEFAULT '{TENANT_POR_DEFECTO}';
70
  """,
71
  ]
72
 
 
134
  def buscar_usuario(email: str) -> sqlite3.Row | None:
135
  with _conexion() as con:
136
  cur = con.execute(
137
+ "SELECT id, nombre, apellido, email, password, tenant "
138
+ "FROM usuarios WHERE email = ? LIMIT 1",
139
  (email,),
140
  )
141
  return cur.fetchone()
142
 
143
 
144
+ def crear_usuario(nombre: str, apellido: str, email: str, password: str, tenant: str) -> None:
145
  with _conexion() as con:
146
  con.execute(
147
+ "INSERT INTO usuarios (nombre, apellido, email, password, tenant) "
148
+ "VALUES (?, ?, ?, ?, ?)",
149
+ (nombre, apellido, email, hash_password(password), tenant),
150
  )
151
 
152
 
backend/app/lab/almacen.py CHANGED
@@ -1,5 +1,8 @@
1
  """Almacén en proceso de resultados de analizador, emparejados por ID de muestra.
2
 
 
 
 
3
  Deliberadamente NO es SQLite: HF Spaces tiene disco efímero, un único worker de uvicorn, y
4
  los resultados son de vida corta (se emparejan con el formulario en minutos). Un dict con
5
  TTL + tope LRU es la primitiva correcta. Clave normalizada (trim + minúsculas) en lectura y
@@ -40,16 +43,24 @@ class AlmacenResultados:
40
  while len(self._datos) > self._max:
41
  self._datos.popitem(last=False) # desaloja el más antiguo
42
 
43
- def obtener(self, muestra_id: str) -> ResultadoMapeado | None:
 
 
 
 
 
44
  with self._lock:
45
  self._barrer_locked()
46
  item = self._datos.get(_clave(muestra_id))
47
- return item[1] if item else None
 
 
48
 
49
- def pendientes(self) -> list[ResultadoMapeado]:
 
50
  with self._lock:
51
  self._barrer_locked()
52
- return [r for (_, r) in reversed(self._datos.values())]
53
 
54
  def _barrer_locked(self) -> None:
55
  ahora = time.monotonic()
 
1
  """Almacén en proceso de resultados de analizador, emparejados por ID de muestra.
2
 
3
+ Segmentado por TENANT: cada resultado lleva la clínica dueña y las lecturas la exigen. Antes era
4
+ un dict global y cualquier sesión autenticada podía leer la muestra de cualquier clínica.
5
+
6
  Deliberadamente NO es SQLite: HF Spaces tiene disco efímero, un único worker de uvicorn, y
7
  los resultados son de vida corta (se emparejan con el formulario en minutos). Un dict con
8
  TTL + tope LRU es la primitiva correcta. Clave normalizada (trim + minúsculas) en lectura y
 
43
  while len(self._datos) > self._max:
44
  self._datos.popitem(last=False) # desaloja el más antiguo
45
 
46
+ def obtener(self, muestra_id: str, tenant: str) -> ResultadoMapeado | None:
47
+ """Resultado de ESE tenant, o None. Un ID de otra clínica se comporta como inexistente.
48
+
49
+ El tenant es obligatorio a propósito: si fuera opcional, olvidarlo en una llamada nueva
50
+ devolvería datos de todas las clínicas en silencio, que es justo el fallo que esto cierra.
51
+ """
52
  with self._lock:
53
  self._barrer_locked()
54
  item = self._datos.get(_clave(muestra_id))
55
+ if item is None or item[1].tenant != tenant:
56
+ return None
57
+ return item[1]
58
 
59
+ def pendientes(self, tenant: str) -> list[ResultadoMapeado]:
60
+ """Cola de ESE tenant, más recientes primero."""
61
  with self._lock:
62
  self._barrer_locked()
63
+ return [r for (_, r) in reversed(self._datos.values()) if r.tenant == tenant]
64
 
65
  def _barrer_locked(self) -> None:
66
  ahora = time.monotonic()
backend/app/routers/auth.py CHANGED
@@ -14,7 +14,7 @@ import asyncio
14
  from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
15
  from pydantic import BaseModel, EmailStr, Field
16
 
17
- from ..config import obtener_config
18
  from ..db import (
19
  buscar_usuario,
20
  crear_usuario,
@@ -50,9 +50,11 @@ class RegistroBody(BaseModel):
50
  password: str = Field(min_length=8, max_length=200)
51
 
52
 
53
- def _emitir_sesion(resp: Response, email: str, nombre: str) -> str:
54
  cfg = obtener_config()
55
- token = firmar_sesion({"email": email, "nombre": nombre})
 
 
56
  csrf = nuevo_token_csrf()
57
  resp.set_cookie(
58
  COOKIE_SESION, token, httponly=True, secure=cfg.cookie_secure,
@@ -94,7 +96,9 @@ async def login(request: Request, body: LoginBody, response: Response) -> dict:
94
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Email o contraseña incorrectos.")
95
 
96
  await asyncio.to_thread(limpiar_intentos, body.email)
97
- csrf = _emitir_sesion(response, usuario["email"], usuario["nombre"])
 
 
98
  return {"ok": True, "nombre": usuario["nombre"], "csrf": csrf}
99
 
100
 
@@ -112,8 +116,13 @@ async def registro(request: Request, body: RegistroBody, response: Response) ->
112
  if await asyncio.to_thread(buscar_usuario, body.email):
113
  raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
114
  # `crear_usuario` hashea con scrypt además de escribir: doble motivo para salir del bucle.
115
- await asyncio.to_thread(crear_usuario, body.nombre, body.apellido, body.email, body.password)
116
- csrf = _emitir_sesion(response, body.email, body.nombre)
 
 
 
 
 
117
  return {"ok": True, "nombre": body.nombre, "csrf": csrf}
118
 
119
 
 
14
  from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
15
  from pydantic import BaseModel, EmailStr, Field
16
 
17
+ from ..config import TENANT_POR_DEFECTO, obtener_config
18
  from ..db import (
19
  buscar_usuario,
20
  crear_usuario,
 
50
  password: str = Field(min_length=8, max_length=200)
51
 
52
 
53
+ def _emitir_sesion(resp: Response, email: str, nombre: str, tenant: str) -> str:
54
  cfg = obtener_config()
55
+ # El tenant viaja en la cookie FIRMADA: el cliente no puede cambiarlo sin romper la firma,
56
+ # y así leerlo no cuesta una consulta a la BD en cada petición.
57
+ token = firmar_sesion({"email": email, "nombre": nombre, "tenant": tenant})
58
  csrf = nuevo_token_csrf()
59
  resp.set_cookie(
60
  COOKIE_SESION, token, httponly=True, secure=cfg.cookie_secure,
 
96
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Email o contraseña incorrectos.")
97
 
98
  await asyncio.to_thread(limpiar_intentos, body.email)
99
+ csrf = _emitir_sesion(
100
+ response, usuario["email"], usuario["nombre"], usuario["tenant"] or TENANT_POR_DEFECTO
101
+ )
102
  return {"ok": True, "nombre": usuario["nombre"], "csrf": csrf}
103
 
104
 
 
116
  if await asyncio.to_thread(buscar_usuario, body.email):
117
  raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.")
118
  # `crear_usuario` hashea con scrypt además de escribir: doble motivo para salir del bucle.
119
+ # La clínica sale de la allowlist (`email=tenant`), no del cuerpo de la petición: dejar
120
+ # elegir tenant al que se registra permitiría entrar en los datos de otra clínica.
121
+ tenant = obtener_config().tenant_de_email(body.email)
122
+ await asyncio.to_thread(
123
+ crear_usuario, body.nombre, body.apellido, body.email, body.password, tenant
124
+ )
125
+ csrf = _emitir_sesion(response, body.email, body.nombre, tenant)
126
  return {"ok": True, "nombre": body.nombre, "csrf": csrf}
127
 
128
 
backend/app/routers/lab.py CHANGED
@@ -24,7 +24,7 @@ from ..schemas_lab import (
24
  ResultadoMapeado,
25
  ResumenPendiente,
26
  )
27
- from ..security.authz import usuario_actual
28
  from ..security.device import verificar_dispositivo
29
  from ..security.rate_limit import limiter
30
 
@@ -36,9 +36,10 @@ router = APIRouter()
36
  async def post_ingesta(
37
  request: Request, # requerido por slowapi
38
  cuerpo: ResultadoAnalizador,
39
- _disp: None = Depends(verificar_dispositivo), # 503 sin keys / 401 sin Bearer válido
40
  ) -> RespuestaIngesta:
41
  mapeado = mapear_resultado(cuerpo)
 
42
  almacen.guardar(mapeado)
43
  if obtener_config().lab_persistir:
44
  # A un hilo como el resto de SQLite: el analizador manda en ráfaga
@@ -61,9 +62,9 @@ async def post_ingesta(
61
  async def get_resultados(
62
  request: Request,
63
  muestra: str = Query(..., min_length=1, max_length=128),
64
- _sesion: dict = Depends(usuario_actual), # 401 si no hay sesión
65
  ) -> ResultadoMapeado:
66
- res = almacen.obtener(muestra)
67
  if res is None:
68
  raise HTTPException(
69
  status.HTTP_404_NOT_FOUND,
@@ -76,7 +77,7 @@ async def get_resultados(
76
  @limiter.limit(obtener_config().limite_lab_consulta)
77
  async def get_pendientes(
78
  request: Request,
79
- _sesion: dict = Depends(usuario_actual),
80
  ) -> list[ResumenPendiente]:
81
  """Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
82
 
@@ -96,5 +97,5 @@ async def get_pendientes(
96
  analitos=len(r.analitos),
97
  no_mapeados=len(r.no_mapeados),
98
  )
99
- for r in almacen.pendientes()
100
  ]
 
24
  ResultadoMapeado,
25
  ResumenPendiente,
26
  )
27
+ from ..security.authz import tenant_de_sesion, usuario_actual
28
  from ..security.device import verificar_dispositivo
29
  from ..security.rate_limit import limiter
30
 
 
36
  async def post_ingesta(
37
  request: Request, # requerido por slowapi
38
  cuerpo: ResultadoAnalizador,
39
+ tenant: str = Depends(verificar_dispositivo), # 503 sin keys / 401 sin Bearer válido
40
  ) -> RespuestaIngesta:
41
  mapeado = mapear_resultado(cuerpo)
42
+ mapeado.tenant = tenant # del servidor, a partir de la API key; nunca del cuerpo
43
  almacen.guardar(mapeado)
44
  if obtener_config().lab_persistir:
45
  # A un hilo como el resto de SQLite: el analizador manda en ráfaga
 
62
  async def get_resultados(
63
  request: Request,
64
  muestra: str = Query(..., min_length=1, max_length=128),
65
+ sesion: dict = Depends(usuario_actual), # 401 si no hay sesión
66
  ) -> ResultadoMapeado:
67
+ res = almacen.obtener(muestra, tenant_de_sesion(sesion))
68
  if res is None:
69
  raise HTTPException(
70
  status.HTTP_404_NOT_FOUND,
 
77
  @limiter.limit(obtener_config().limite_lab_consulta)
78
  async def get_pendientes(
79
  request: Request,
80
+ sesion: dict = Depends(usuario_actual),
81
  ) -> list[ResumenPendiente]:
82
  """Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
83
 
 
97
  analitos=len(r.analitos),
98
  no_mapeados=len(r.no_mapeados),
99
  )
100
+ for r in almacen.pendientes(tenant_de_sesion(sesion))
101
  ]
backend/app/schemas_lab.py CHANGED
@@ -94,6 +94,10 @@ class ValorAnalito(BaseModel):
94
  class ResultadoMapeado(BaseModel):
95
  """Resultado listo para el frontend: analitos por clave canónica + no reconocidos."""
96
 
 
 
 
 
97
  muestra_id: str
98
  instrumento_id: str
99
  momento: datetime
 
94
  class ResultadoMapeado(BaseModel):
95
  """Resultado listo para el frontend: analitos por clave canónica + no reconocidos."""
96
 
97
+ # Clínica dueña del resultado. Lo pone el SERVIDOR a partir de la API key del dispositivo
98
+ # (`verificar_dispositivo`), nunca el cuerpo de la ingesta: si el puente pudiera declararlo,
99
+ # mentir en un campo bastaría para escribir en la clínica de otro.
100
+ tenant: str = ""
101
  muestra_id: str
102
  instrumento_id: str
103
  momento: datetime
backend/app/security/authz.py CHANGED
@@ -10,6 +10,7 @@ import hmac
10
 
11
  from fastapi import Cookie, Header, HTTPException, Request, status
12
 
 
13
  from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion
14
 
15
 
@@ -22,6 +23,17 @@ def usuario_actual(request: Request) -> dict:
22
  return sesion
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
25
  def verificar_csrf(
26
  request: Request,
27
  x_csrf_token: str | None = Header(default=None, alias=CABECERA_CSRF),
 
10
 
11
  from fastapi import Cookie, Header, HTTPException, Request, status
12
 
13
+ from ..config import TENANT_POR_DEFECTO
14
  from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion
15
 
16
 
 
23
  return sesion
24
 
25
 
26
+ def tenant_de_sesion(sesion: dict) -> str:
27
+ """Clínica del usuario de la sesión.
28
+
29
+ Las sesiones emitidas ANTES de que existiera el tenant no lo llevan en la cookie firmada, y
30
+ duran hasta `session_max_age_s`. Caen al tenant por defecto, que es donde también viven los
31
+ dispositivos que no declaran clínica: un despliegue de una sola clínica —el caso normal—
32
+ sigue funcionando durante la transición sin que nadie tenga que volver a entrar.
33
+ """
34
+ return sesion.get("tenant") or TENANT_POR_DEFECTO
35
+
36
+
37
  def verificar_csrf(
38
  request: Request,
39
  x_csrf_token: str | None = Header(default=None, alias=CABECERA_CSRF),
backend/app/security/device.py CHANGED
@@ -7,15 +7,19 @@ verificar_password en db.py). Falla cerrado: sin keys configuradas, la ingesta n
7
 
8
  from __future__ import annotations
9
 
10
- import hmac
11
-
12
  from fastapi import Header, HTTPException, status
13
 
14
  from ..config import obtener_config
15
 
16
 
17
- def verificar_dispositivo(authorization: str | None = Header(default=None)) -> None:
18
- """Dependencia para /api/lab/ingesta. 503 si no hay keys; 401 si la Bearer no coincide."""
 
 
 
 
 
 
19
  cfg = obtener_config()
20
  if not cfg.lab_api_keys:
21
  raise HTTPException(
@@ -25,5 +29,7 @@ def verificar_dispositivo(authorization: str | None = Header(default=None)) -> N
25
  token = ""
26
  if authorization and authorization.lower().startswith("bearer "):
27
  token = authorization[7:].strip()
28
- if not token or not any(hmac.compare_digest(token, k) for k in cfg.lab_api_keys):
 
29
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Dispositivo no autorizado.")
 
 
7
 
8
  from __future__ import annotations
9
 
 
 
10
  from fastapi import Header, HTTPException, status
11
 
12
  from ..config import obtener_config
13
 
14
 
15
+ def verificar_dispositivo(authorization: str | None = Header(default=None)) -> str:
16
+ """Dependencia para /api/lab/ingesta: devuelve el TENANT dueño de la clave.
17
+
18
+ Antes devolvía None y la clave era anónima: cualquier dispositivo escribía en un almacén
19
+ global y cualquier sesión lo leía. El tenant sale de la configuración del servidor, nunca
20
+ del cuerpo de la petición: si el puente pudiera declarar su clínica, bastaría mentir en un
21
+ campo para escribir en la de otro.
22
+ """
23
  cfg = obtener_config()
24
  if not cfg.lab_api_keys:
25
  raise HTTPException(
 
29
  token = ""
30
  if authorization and authorization.lower().startswith("bearer "):
31
  token = authorization[7:].strip()
32
+ tenant = cfg.tenant_de_clave_dispositivo(token) if token else None
33
+ if tenant is None:
34
  raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Dispositivo no autorizado.")
35
+ return tenant
backend/tests/test_tenant_lab.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aislamiento por clínica de los resultados de analizador (ARCHITECTURE_REVIEW §2.1).
2
+
3
+ El almacén era un dict global sin dueño: cualquier sesión autenticada podía leer la muestra de
4
+ cualquier clínica, y `/api/lab/pendientes` enumeraba TODAS. Cada `muestra_id` abre en
5
+ `/api/lab/resultados` el panel completo más las pistas de paciente (nombre de la mascota, raza,
6
+ sexo), así que el aislamiento es lo único que separa a una clínica de los datos de otra.
7
+
8
+ Dos cosas que estas pruebas fijan y no pueden relajarse: el tenant lo pone el SERVIDOR (de la API
9
+ key en la ingesta, de la cookie firmada en la lectura) y una muestra de otra clínica se comporta
10
+ como inexistente, no como prohibida.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pytest
16
+ from fastapi.testclient import TestClient
17
+
18
+ from app.config import TENANT_POR_DEFECTO, obtener_config
19
+ from app.lab.almacen import almacen
20
+ from app.main import app
21
+
22
+ CLAVE_A = "clave-de-la-clinica-a"
23
+ CLAVE_B = "clave-de-la-clinica-b"
24
+
25
+ PAYLOAD = {
26
+ "muestra_id": "M-1",
27
+ "instrumento_id": "vetscan-1",
28
+ "fabricante": "Abaxis",
29
+ "observaciones": [{"codigo_prueba": "GLU", "valor": "5.0", "unidad": "mmol/L"}],
30
+ "momento": "2026-07-25T10:00:00Z",
31
+ }
32
+
33
+
34
+ @pytest.fixture
35
+ def cliente():
36
+ almacen._datos.clear()
37
+ with TestClient(app) as c:
38
+ yield c
39
+ almacen._datos.clear()
40
+
41
+
42
+ @pytest.fixture
43
+ def dos_clinicas(monkeypatch):
44
+ """Dos dispositivos y dos cuentas, cada uno en su clínica."""
45
+ cfg = obtener_config()
46
+ monkeypatch.setattr(cfg, "lab_api_keys", [f"clinica-a:{CLAVE_A}", f"clinica-b:{CLAVE_B}"])
47
+ monkeypatch.setattr(
48
+ cfg, "registro_allowlist", ["a@example.com=clinica-a", "b@example.com=clinica-b"]
49
+ )
50
+ monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True)
51
+
52
+
53
+ def _ingerir(cliente, clave, muestra):
54
+ return cliente.post(
55
+ "/api/lab/ingesta",
56
+ json={**PAYLOAD, "muestra_id": muestra},
57
+ headers={"Authorization": f"Bearer {clave}"},
58
+ )
59
+
60
+
61
+ def _sesion(cliente, email):
62
+ """Sesión para ese email. La BD de pruebas se comparte, así que si la cuenta ya existe de
63
+ otra prueba se entra en vez de darla de alta (el tenant quedó fijado al crearla)."""
64
+ r = cliente.post(
65
+ "/api/auth/registro",
66
+ json={"nombre": "V", "apellido": "Vet", "email": email, "password": "clave-segura-1"},
67
+ )
68
+ if r.status_code == 409:
69
+ r = cliente.post(
70
+ "/api/auth/login", json={"email": email, "password": "clave-segura-1"}
71
+ )
72
+ assert r.status_code == 200, r.text
73
+ return r
74
+
75
+
76
+ def test_una_clinica_no_ve_la_muestra_de_otra(cliente, dos_clinicas):
77
+ """El hallazgo central de §2.1: cualquier sesión leía cualquier muestra."""
78
+ assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200
79
+
80
+ _sesion(cliente, "b@example.com")
81
+ r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"})
82
+
83
+ # 404 y no 403: existir o no en otra clínica tampoco es información que se dé.
84
+ assert r.status_code == 404
85
+
86
+
87
+ def test_la_propia_clinica_si_la_ve(cliente, dos_clinicas):
88
+ """El complemento obligatorio: aislar no puede romper el caso legítimo."""
89
+ assert _ingerir(cliente, CLAVE_A, "M-DE-A").status_code == 200
90
+
91
+ _sesion(cliente, "a@example.com")
92
+ r = cliente.get("/api/lab/resultados", params={"muestra": "M-DE-A"})
93
+
94
+ assert r.status_code == 200
95
+ assert r.json()["muestra_id"] == "M-DE-A"
96
+
97
+
98
+ def test_pendientes_solo_enumera_lo_propio(cliente, dos_clinicas):
99
+ """`pendientes` era el volcado en una petición: ahora sólo lista la clínica de la sesión."""
100
+ _ingerir(cliente, CLAVE_A, "M-DE-A")
101
+ _ingerir(cliente, CLAVE_B, "M-DE-B")
102
+
103
+ _sesion(cliente, "b@example.com")
104
+ r = cliente.get("/api/lab/pendientes")
105
+
106
+ assert r.status_code == 200
107
+ ids = [x["muestra_id"] for x in r.json()]
108
+ assert ids == ["M-DE-B"]
109
+
110
+
111
+ def test_el_puente_no_puede_declarar_su_clinica(cliente, dos_clinicas):
112
+ """El tenant sale de la API key; si viniera del cuerpo, mentir bastaría para escribir en otra."""
113
+ r = cliente.post(
114
+ "/api/lab/ingesta",
115
+ json={**PAYLOAD, "muestra_id": "M-MENTIRA", "tenant": "clinica-b"},
116
+ headers={"Authorization": f"Bearer {CLAVE_A}"},
117
+ )
118
+ assert r.status_code == 200
119
+
120
+ _sesion(cliente, "b@example.com")
121
+ assert cliente.get("/api/lab/resultados", params={"muestra": "M-MENTIRA"}).status_code == 404
122
+
123
+
124
+ def test_clave_sin_clinica_declarada_cae_en_la_de_por_defecto(cliente, monkeypatch):
125
+ """Compatibilidad: un despliegue de una sola clínica no declara tenants y sigue funcionando."""
126
+ cfg = obtener_config()
127
+ monkeypatch.setattr(cfg, "lab_api_keys", ["clave-suelta"])
128
+ monkeypatch.setattr(cfg, "registro_abierto", True)
129
+ monkeypatch.setattr(cfg, "lab_pendientes_habilitado", True)
130
+
131
+ assert _ingerir(cliente, "clave-suelta", "M-SUELTA").status_code == 200
132
+ _sesion(cliente, "suelto@example.com")
133
+
134
+ r = cliente.get("/api/lab/resultados", params={"muestra": "M-SUELTA"})
135
+ assert r.status_code == 200
136
+ assert r.json()["tenant"] == TENANT_POR_DEFECTO
137
+
138
+
139
+ def test_clave_invalida_sigue_siendo_401(cliente, dos_clinicas):
140
+ assert _ingerir(cliente, "no-es-una-clave", "M-X").status_code == 401
141
+
142
+
143
+ def test_el_tenant_de_la_clave_se_resuelve_bien():
144
+ """Unidad sobre el parseo `tenant:clave`, que es lo que sostiene todo lo anterior."""
145
+ cfg = obtener_config().model_copy(
146
+ update={"lab_api_keys": ["clinica-a:secreta-a", "suelta-sin-tenant"]}
147
+ )
148
+ assert cfg.tenant_de_clave_dispositivo("secreta-a") == "clinica-a"
149
+ assert cfg.tenant_de_clave_dispositivo("suelta-sin-tenant") == TENANT_POR_DEFECTO
150
+ assert cfg.tenant_de_clave_dispositivo("clinica-a") is None # el prefijo no es la clave
151
+ assert cfg.tenant_de_clave_dispositivo("") is None