Proyectos

De dependencia compartida a microservicio: cuándo dejar de importar código común

Tenía un asistente conversacional funcionando en un proyecto y quise usarlo en los otros tres. Lo empaqueté como dependencia de Python y cada proyecto lo importaba. Durante unos meses pareció la decisión correcta. Dejó de serlo en cuanto tuve que actualizarlo.

Servicio propio — demostración de capacidad técnica, no producto con clientes
Tests en la suite
126 → 142
Ataques cruzados
1211 bloqueados al auditar
Productos servidos
4un solo despliegue

¿Qué se rompe cuando compartes código entre proyectos?

El problema no era el código: era que el código había nacido para un solo proyecto.

Al empaquetarlo como dependencia, cada producto acabó con una versión distinta instalada. Cada cambio obligaba a reconstruir las imágenes de todos. Y lo peor: actualizarlo para un proyecto lo rompía en otro, porque las decisiones que llevaba dentro estaban tomadas pensando en el caso original, no en un servicio común.

Un paquete compartido nacido de un caso concreto no es reutilizable. Es el mismo caso concreto, ahora instalado en cuatro sitios.

¿Por qué no publicar el paquete y ya está?

Fue lo primero que consideré: subirlo a un índice de paquetes, versionarlo en condiciones y que cada proyecto fijara su versión. Lo descarté por dos razones.

La primera es que resolvía la sincronización pero no el problema de fondo. Seguiría siendo código diseñado para un caso, ejecutándose dentro de cuatro procesos distintos.

La segunda es de dimensionado: publicar un paquete tiene sentido cuando lo consumen terceros. Aquí el único consumidor soy yo, en cuatro productos. Montar esa ceremonia es infraestructura que tendría que mantener sin necesitarla. Con un repositorio propio y un servicio desplegado tengo la misma garantía de versión única con menos piezas.

El problema que no era de código: una sola base vectorial

Aquí está la parte que no vi al principio.

El asistente responde con RAG sobre el contenido de cada producto, y eso significa embeddings. En el diseño original había una sola base vectorial, la del proyecto para el que nació. Servía para ese chatbot y para ninguno más: meter en ella el contenido de los demás habría hecho que cada uno respondiera con información del resto.

Era el mismo error que el del paquete, una capa más abajo. Algo pensado para un caso, forzado a servir a cuatro.

Con el microservicio, cada proyecto tiene su propia base vectorizada y sus propios parámetros de chatbot. El servicio es común; los datos y la configuración, de cada uno. Esa separación es lo que hace el asistente reutilizable de verdad — no el hecho de vivir en un repositorio aparte.

¿Qué me costó el cambio?

Una tarde entera de reconfigurar la infraestructura de producción. Y a cambio acepté tres costes permanentes.

Un salto de red donde había una llamada a función: lo instantáneo pasa a depender de que otro servicio conteste.

Un punto único de caída. Antes, si el asistente fallaba, fallaba en un proyecto. Ahora los cuatro dependen del mismo servicio. Por eso la degradación es parte del diseño y no un apaño: cuando el asistente no responde, la página carga entera y el chat muestra un mensaje pidiendo reintentar más tarde. Nunca un error crudo ni un indicador de carga infinito. Un servicio compartido se cae alguna vez, y lo que ve el usuario en ese momento decide si la aplicación parece rota o parece bien hecha.

Y un despliegue más que mantener, con su propio ciclo de versiones. A cambio, actualizo un repositorio y el cambio llega a los cuatro productos: sin reconstruir imágenes, sin tocar cuatro repos, sin descubrir tres semanas después que uno se quedó atrás.

¿Cómo sé que un proyecto no puede leer los datos de otro?

Porque lo he comprobado atacándolo, no leyendo el código.

La identidad del proyecto se deriva de la credencial, nunca de la petición. Cada inquilino autentica con su propio secreto y el servicio deduce de cuál se trata comparándolo; no hay ninguna cabecera, parámetro ni campo del cuerpo que pueda alterarlo. Verificado por cuatro vías distintas: cabecera de inquilino, campo en el cuerpo, identificador de sesión ajeno, y las tres combinadas.

El aislamiento del contenido es por destino, no por filtro. El servicio no almacena embeddings de nadie: consulta el backend del propio proyecto, con una URL y un token de salida distintos para cada uno. No hay una base común con una condición que se pueda olvidar en alguna ruta de consulta.

Un intento cruzado devuelve 404, no 403. Un 403 confirmaría que el recurso existe. Es una diferencia de un dígito y decide si un atacante puede enumerar.

Y una precisión que conviene hacer aunque reste: sesiones y mensajes comparten tabla, separados por columna. Lo que está físicamente separado es el almacén vectorial, que es donde vive el contenido del cliente.

Lo que encontré al auditarlo: la cadena vacía

Pedí una auditoría de aislamiento con una instrucción explícita: dime la verdad aunque sea incómoda, un "no está garantizado" me sirve igual. Doce ataques cruzados. Once rebotaron. Uno no.

Con un token heredado de la época de un solo proyecto, el listado de conversaciones devolvía las de todos los proyectos. No el contenido de los mensajes, pero sí sus títulos — y los títulos los genera el modelo a partir de la primera pregunta del usuario, así que eran contenido, no metadatos.

La causa cabía en una línea: el filtro por proyecto era condicional. Si el proyecto era la cadena vacía, la condición era falsa y la consulta salía sin filtrar.

Detalle que explica por qué no saltó antes: había 126 tests en verde, y entre ellos uno que comprobaba el listado y otro que comprobaba el token heredado. Cada mitad del fallo estaba cubierta. Su intersección no.

La causa real no era la línea. Era que la cadena vacía significaba dos cosas a la vez: "sin proyecto" y, por omisión del filtro, "todos los proyectos". Un valor que representa ausencia de identidad tiene que cerrar una consulta, no abrirla.

¿Por qué el arreglo no fue denegar?

La salida obvia era "sin proyecto, deniega". Era la equivocada, y se vio antes de escribir código: los dos consumidores vivos tenían el 100 % de sus conversaciones bajo ese valor. Denegar los habría dejado a los dos sin listado.

El arreglo fue quitarle el carácter especial: filtrar siempre, sin ramas, de modo que la cadena vacía pase a ser un espacio de nombres ordinario y cerrado — las sesiones de quien no tiene proyecto, y nada más. Sin comodines y sin romper a nadie.

Los tests se escribieron primero y en rojo. Uno de ellos deriva las operaciones por introspección en lugar de enumerarlas, así que cualquier operación futura hereda la regla sin que nadie tenga que acordarse de añadirla a una lista. De 126 tests a 142, ninguno roto, y los doce ataques originales re-ejecutados sin tocarlos.

¿Cuándo aplicaría esto y cuándo no?

Sí, cuando un código compartido nació para un caso concreto y vas por la segunda adaptación forzada.

No, si solo hay dos consumidores y el código es estable: un paquete versionado cuesta mucho menos que un despliegue.

La pregunta que lo decide no es cuántos proyectos lo usan, sino si el código compartido tiene estado o configuración propia de cada consumidor. Si lo tiene, tarde o temprano necesita ser un servicio. Si no, un paquete basta.

Un valor centinela que significa "ausencia" acaba significando "todos" en cuanto alguien lo mete en una condición. La solución no es prohibirlo: es que deje de ser especial.

Stack

  • Django
  • Django REST Framework
  • Puertos y adaptadores
  • Base vectorial por proyecto
  • Inferencia sobre modelo local
  • Federación contra el backend de cada proyecto