Arquitectura multi-tenant — decisión de aislamiento
Esta skill nace de una auditoría de seguridad real sobre un SaaS multi-tenant en producción: 5 fugas de aislamiento (IDOR) encontradas y corregidas en un solo día. No viene de un libro — viene de bugs reales. Por eso el checklist de la sección 3 pesa tanto como la tabla de estrategias.
1. Las 3 estrategias de aislamiento
| Estrategia |
Coste |
Complejidad operativa |
Garantía de aislamiento |
Migrar a la siguiente |
tenant_id compartido — una tabla, una columna tenant_id en cada fila |
Bajo — un solo servidor, un solo schema |
Baja — un pool de conexiones, una migración para todos |
Depende 100% de que CADA query filtre por tenant_id. Un solo endpoint que lo olvide es una fuga (IDOR) |
Media-alta — hay que particionar datos existentes por tenant |
| Schema por tenant — mismo servidor Postgres, un schema por cliente |
Medio — mismo servidor, N schemas, N migraciones a aplicar (o un runner que itera) |
Media — el aislamiento lo da Postgres, no tu código, pero conectar al schema correcto en cada request es una responsabilidad más |
Aislamiento fuerte a nivel de motor; un bug de aplicación no cruza schemas |
Baja — mover un tenant a su propia BD es un pg_dump/pg_restore de un solo schema |
| BD por tenant — aislamiento físico total |
Alto — N bases de datos, N conexiones, N migraciones, backups por separado |
Alta — cada tenant es una unidad operativa propia (monitorización, escalado, incidentes) |
Total — un bug de aplicación no puede filtrar entre tenants ni por accidente |
N/A — ya es el aislamiento máximo |
Recomendación por defecto para un SaaS B2B pequeño/mediano: tenant_id compartido. Es el que menos fricción operativa añade mientras el número de tenants y el tamaño de los datos son manejables, y el que más rápido se construye. El coste que paga a cambio es que el aislamiento vive enteramente en la disciplina de código — de ahí el checklist de la sección 3.
2. Cuándo NO usar el default
No uses tenant_id compartido cuando:
- Compliance exige aislamiento físico — sanidad (HIPAA), banca, o cualquier contrato que exija que los datos de un cliente nunca compartan disco/proceso con los de otro. Ahí vas directo a BD por tenant, sin importar el tamaño.
- Un tenant es mucho más grande que el resto y necesita rendimiento dedicado (su propio índice, su propio tuning, su propia ventana de mantenimiento) sin que un vecino ruidoso lo degrade. Esquema o BD por tenant para ese cliente concreto; el resto puede seguir en
tenant_id compartido (modelo híbrido).
3. El checklist IDOR (la lección real)
Regla: toda query que recibe un id externo controlado por el cliente (path param, query param o campo del body) necesita una de estas dos cosas antes de tocar la BD:
tenant_id en el filtro de la query, o
- Una validación explícita de que el recurso referenciado pertenece al tenant del usuario autenticado, antes de usarlo.
Ejemplo anonimizado del bug real
Un endpoint recibe en el body el id de OTRO recurso relacionado (no el recurso principal de la URL) y lo usa sin validar que pertenece al mismo tenant que el usuario autenticado:
# ❌ INCORRECTO — el id del body no se valida contra el tenant
@router.patch("/shift-changes/{change_id}/review")
def review_shift_change(change_id: int, body: ReviewInput, user: User = Depends(get_current_user)):
change = db.query(ShiftChange).filter(
ShiftChange.id == change_id,
ShiftChange.tenant_id == user.tenant_id, # el recurso PRINCIPAL sí está filtrado
).first()
if not change:
raise HTTPException(404)
# body.reviewer_id llega del cliente y NO se valida contra el tenant.
# Un usuario del Tenant A puede pasar el id de un empleado del Tenant B
# y el sistema lo acepta como revisor válido.
change.reviewer_id = body.reviewer_id
db.commit()
# ✅ CORRECTO — todo id que entra por el body se valida contra el tenant también
@router.patch("/shift-changes/{change_id}/review")
def review_shift_change(change_id: int, body: ReviewInput, user: User = Depends(get_current_user)):
change = db.query(ShiftChange).filter(
ShiftChange.id == change_id,
ShiftChange.tenant_id == user.tenant_id,
).first()
if not change:
raise HTTPException(404)
reviewer = db.query(Employee).filter(
Employee.id == body.reviewer_id,
Employee.tenant_id == user.tenant_id, # el id del BODY también se valida
).first()
if not reviewer:
raise HTTPException(400, detail="Revisor no encontrado en este tenant")
change.reviewer_id = reviewer.id
db.commit()
La fuga no está en el recurso principal de la URL (ese casi siempre se filtra bien porque es el "camino feliz" que se prueba primero). Está en los ids secundarios que llegan por el body o por query params — se asume que si el usuario está autenticado, cualquier id que envíe es válido. No lo es: hay que probar que ese id también pertenece a su tenant.
Checklist para cada endpoint nuevo
4. Siguientes pasos
auth-multitenant.md — JWT claim vs resolución en BD, modelo de roles, patrón de superadmin cross-tenant.
testing-multitenant.md — fixture de dos tenants reales, disciplina de verificación con git stash.
billing-stripe.md — verificación de firma de webhook, relación tenant ↔ customer_id.
1---2name: saas-multitenant-architecture3description: Arquitectura de aislamiento multi-tenant para un SaaS B2B desde cero — elige entre tenant_id compartido, schema por tenant o BD por tenant, aplica el checklist IDOR a cada query, y enlaza con auth, testing y billing multi-tenant. Úsalo al arrancar un SaaS nuevo o al auditar el aislamiento de uno existente.4---56# Arquitectura multi-tenant — decisión de aislamiento78Esta skill nace de una auditoría de seguridad real sobre un SaaS multi-tenant en producción: 5 fugas de aislamiento (IDOR) encontradas y corregidas en un solo día. No viene de un libro — viene de bugs reales. Por eso el checklist de la sección 3 pesa tanto como la tabla de estrategias.910## 1. Las 3 estrategias de aislamiento1112| Estrategia | Coste | Complejidad operativa | Garantía de aislamiento | Migrar a la siguiente |13|---|---|---|---|---|14| **`tenant_id` compartido** — una tabla, una columna `tenant_id` en cada fila | Bajo — un solo servidor, un solo schema | Baja — un pool de conexiones, una migración para todos | Depende 100% de que CADA query filtre por `tenant_id`. Un solo endpoint que lo olvide es una fuga (IDOR) | Media-alta — hay que particionar datos existentes por tenant |15| **Schema por tenant** — mismo servidor Postgres, un schema por cliente | Medio — mismo servidor, N schemas, N migraciones a aplicar (o un runner que itera) | Media — el aislamiento lo da Postgres, no tu código, pero conectar al schema correcto en cada request es una responsabilidad más | Aislamiento fuerte a nivel de motor; un bug de aplicación no cruza schemas | Baja — mover un tenant a su propia BD es un `pg_dump`/`pg_restore` de un solo schema |16| **BD por tenant** — aislamiento físico total | Alto — N bases de datos, N conexiones, N migraciones, backups por separado | Alta — cada tenant es una unidad operativa propia (monitorización, escalado, incidentes) | Total — un bug de aplicación no puede filtrar entre tenants ni por accidente | N/A — ya es el aislamiento máximo |1718**Recomendación por defecto para un SaaS B2B pequeño/mediano: `tenant_id` compartido.** Es el que menos fricción operativa añade mientras el número de tenants y el tamaño de los datos son manejables, y el que más rápido se construye. El coste que paga a cambio es que el aislamiento vive enteramente en la disciplina de código — de ahí el checklist de la sección 3.1920## 2. Cuándo NO usar el default2122No uses `tenant_id` compartido cuando:23- **Compliance exige aislamiento físico** — sanidad (HIPAA), banca, o cualquier contrato que exija que los datos de un cliente nunca compartan disco/proceso con los de otro. Ahí vas directo a BD por tenant, sin importar el tamaño.24- **Un tenant es mucho más grande que el resto** y necesita rendimiento dedicado (su propio índice, su propio tuning, su propia ventana de mantenimiento) sin que un vecino ruidoso lo degrade. Esquema o BD por tenant para ese cliente concreto; el resto puede seguir en `tenant_id` compartido (modelo híbrido).2526## 3. El checklist IDOR (la lección real)2728**Regla:** toda query que recibe un `id` externo controlado por el cliente (path param, query param o campo del body) necesita una de estas dos cosas antes de tocar la BD:291. `tenant_id` en el filtro de la query, o302. Una validación explícita de que el recurso referenciado pertenece al tenant del usuario autenticado, antes de usarlo.3132### Ejemplo anonimizado del bug real3334Un endpoint recibe en el body el id de OTRO recurso relacionado (no el recurso principal de la URL) y lo usa sin validar que pertenece al mismo tenant que el usuario autenticado:3536```python37# ❌ INCORRECTO — el id del body no se valida contra el tenant38@router.patch("/shift-changes/{change_id}/review")39def review_shift_change(change_id: int, body: ReviewInput, user: User = Depends(get_current_user)):40 change = db.query(ShiftChange).filter(41 ShiftChange.id == change_id,42 ShiftChange.tenant_id == user.tenant_id, # el recurso PRINCIPAL sí está filtrado43 ).first()44 if not change:45 raise HTTPException(404)4647 # body.reviewer_id llega del cliente y NO se valida contra el tenant.48 # Un usuario del Tenant A puede pasar el id de un empleado del Tenant B49 # y el sistema lo acepta como revisor válido.50 change.reviewer_id = body.reviewer_id51 db.commit()52```5354```python55# ✅ CORRECTO — todo id que entra por el body se valida contra el tenant también56@router.patch("/shift-changes/{change_id}/review")57def review_shift_change(change_id: int, body: ReviewInput, user: User = Depends(get_current_user)):58 change = db.query(ShiftChange).filter(59 ShiftChange.id == change_id,60 ShiftChange.tenant_id == user.tenant_id,61 ).first()62 if not change:63 raise HTTPException(404)6465 reviewer = db.query(Employee).filter(66 Employee.id == body.reviewer_id,67 Employee.tenant_id == user.tenant_id, # el id del BODY también se valida68 ).first()69 if not reviewer:70 raise HTTPException(400, detail="Revisor no encontrado en este tenant")7172 change.reviewer_id = reviewer.id73 db.commit()74```7576**La fuga no está en el recurso principal de la URL** (ese casi siempre se filtra bien porque es el "camino feliz" que se prueba primero). **Está en los ids secundarios que llegan por el body o por query params** — se asume que si el usuario está autenticado, cualquier id que envíe es válido. No lo es: hay que probar que ese id también pertenece a su tenant.7778### Checklist para cada endpoint nuevo79- [ ] ¿El recurso principal de la URL filtra por `tenant_id`?80- [ ] ¿Todo id que llega por el body se valida contra el tenant del usuario autenticado, no solo contra su propia tabla?81- [ ] ¿Todo id que llega por query params (filtros, ordenación por FK) se valida igual?82- [ ] ¿El test de regresión usa DOS tenants reales e intenta cruzar datos entre ellos? (ver `testing-multitenant.md`)8384## 4. Siguientes pasos8586- **`auth-multitenant.md`** — JWT claim vs resolución en BD, modelo de roles, patrón de superadmin cross-tenant.87- **`testing-multitenant.md`** — fixture de dos tenants reales, disciplina de verificación con `git stash`.88- **`billing-stripe.md`** — verificación de firma de webhook, relación tenant ↔ `customer_id`.