Multi-Tenancy en PostgreSQL
Cuadrante 04_EXPLANATION — Por qué TimeLiber utiliza una estrategia de multi-tenancy aislada a nivel de roles de base de datos en lugar de esquemas o un usuario único. Para conectarte a estas bases de datos → ../02_HOW_TO/connect-to-infra.md
1. El Problema del "Usuario Único"
En arquitecturas tradicionales (y muchos tutoriales básicos), es común ver un único usuario (postgres o admin) que es dueño de todas las bases de datos de una aplicación.
Si hiciéramos esto en el monorepo TimeLiber, la API de Gastro se conectaría a PostgreSQL con las mismas credenciales que la API de Travel.
Consecuencias de esto:
- Riesgo de seguridad crítico: Si la API de Gastro tiene una vulnerabilidad (ej. SQL Injection) o es comprometida, el atacante tendría acceso total para leer, modificar o borrar los datos de reservaciones de Travel.
- Acoplamiento accidental: Un desarrollador podría cruzar tablas (JOIN travel.reservations ...) desde el código de Gastro, rompiendo los límites del dominio y creando una deuda técnica masiva (BOLA - Broken Object Level Authorization a nivel de arquitectura).
2. La Solución: Aislamiento por Roles (SV Standard)
En la infraestructura de TimeLiber adoptamos un enfoque de grado "Silicon Valley" para Microservicios: Aislamiento Multi-Tenant.
Tenemos un solo motor (contenedor) de PostgreSQL corriendo para ahorrar costos de cómputo y facilitar el mantenimiento (ejecutar 5 contenedores de PostgreSQL consumiría mucha memoria innecesariamente). Sin embargo, dentro de este motor único, imponemos límites estrictos:
- El Superusuario (
timeliber_admin): Solo es utilizado para tareas de administración y creación inicial. Las aplicaciones nunca conocen su contraseña. - Roles Aislados (
gastro_user,travel_user): Cada vertical tiene su propio usuario. - Bases de Datos Dedicadas (
gastro_db,travel_db): Cada usuario es designado explícitamente como "dueño" (OWNER) de su base de datos respectiva.
Resultado: gastro_user puede hacer lo que quiera dentro de gastro_db. Pero si intenta hacer \c travel_db o leer una tabla ajena, PostgreSQL rechazará la conexión a nivel de socket. El aislamiento está garantizado por el motor de base de datos, no por el código de la aplicación.
3. ¿Cómo se implementa esto técnicamente?
El aislamiento se configura dinámicamente durante el primer arranque del contenedor de PostgreSQL.
Usamos el mecanismo nativo de la imagen oficial de Docker para PostgreSQL: montar scripts en /docker-entrypoint-initdb.d/.
Si inspeccionas infra/postgres/init/, verás scripts como 00_travel.sh:
#!/bin/bash
set -e
echo "Provisioning Travel Database..."
# Con el superusuario, creamos el rol sin privilegios de superusuario, solo login
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" \
-c "CREATE ROLE \"${TRAVEL_DB_USER}\" WITH LOGIN PASSWORD \$\$${TRAVEL_DB_PASSWORD}\$\$;"
# Creamos la DB y asignamos al rol recién creado como el único dueño
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" \
-c "CREATE DATABASE travel_db OWNER \"${TRAVEL_DB_USER}\";"
Nota de diseño: Usamos
.sh(Bash) en lugar de.sqlpuro porque nos permite inyectar de forma segura las contraseñas leídas desde las variables de entorno (TRAVEL_DB_PASSWORD) deinfra/core/.env.
4. Multi-Tenancy dentro de una vertical
Es importante distinguir dos niveles de Multi-Tenancy:
- Aislamiento entre Verticales (Arquitectura): Separar Gastro de Travel. Esto se logra con las bases de datos y roles que explicamos arriba.
- Aislamiento entre Clientes/Tenants (Aplicación): Separar los datos del Restaurante A de los del Restaurante B (ambos usando Gastro).
Dentro de gastro_db, los datos de todos los restaurantes (tenants) residen en las mismas tablas. Este segundo nivel de aislamiento es manejado por la capa de aplicación (SQLAlchemy).
Siguiendo las AGENTS.md Kill Switches:
"Todo query a base de datos DEBE estar filtrado por property_id o tenant_id. Aislar los datos entre clientes es la ley #1 del monorepo."
Las aplicaciones utilizan un tenant_id en el JWT del usuario autenticado para filtrar las sentencias en cada request, garantizando que un restaurante no pueda ver los menús de otro.
No utilizamos RLS (Row-Level Security) de PostgreSQL actualmente para el aislamiento de clientes, preferimos mantener la lógica de autorización explícita en la capa de la aplicación (Python) por rendimiento y trazabilidad.
Excepción deliberada — Commerce: commerce_db aplica filtro explícito en
aplicación y además ENABLE/FORCE ROW LEVEL SECURITY como defensa en
profundidad. Para que ownership no evada RLS, commerce_owner ejecuta sólo
migraciones y commerce_runtime asume el rol limitado commerce_app durante
las transacciones de requests.
TimeLiber Connect comparte el motor timeliber-postgres, no la base de datos:
su session store vive en commerce_connect_db. Prisma Migrate usa
commerce_connect_owner y el proceso usa commerce_connect_runtime; ambos son
NOSUPERUSER y NOBYPASSRLS. Esa base guarda sesiones Shopify cifradas y no se
consulta directamente desde Commerce: el boundary entre servicios es HTTP REST
interno autenticado.
Referencias
- ../03_REFERENCE/services-catalog.md — Ver todos los usuarios y bases de datos creadas.
- ../02_HOW_TO/connect-to-infra.md — Ejemplos de conexión.