Una arquitectura multi-tenant que sobrevive a la primera revisión enterprise
Membership a través de una tabla de join tipada, aislamiento aplicado por row-level security en SQL, customer Stripe por tenant, audit log desde el primer día, suspensión de tenant y borrado GDPR que funcionan de verdad. La construimos así para que, cuando llegue la pregunta "¿cómo garantizáis que el cliente A no puede ver al cliente B?", la respuesta quepa en una sola pantalla de código.
El problema
La mayoría de los SaaS atornilla la multi-tenancy en la capa equivocada
El patrón que se rompe bajo auditoría es el que pone la autorización en el código de la aplicación. Un ingeniero junior añade una ruta que se olvida del scope del tenant; un refactor mueve un helper a un sitio donde el tenant no está en el contexto; una integración de terceros escribe en la fila equivocada. La respuesta correcta es poner el borde del tenant en la base de datos, donde no se puede olvidar. La tabla de membership es la ley; row-level security es la aplicación; el código aplicativo deja de cargar con la pregunta. Lo diseñamos desde el principio porque ponerlo a posteriori después del primer contrato enterprise cuesta el doble.
Cómo lo abordamos
Las seis capas de una arquitectura multi-tenant
Cada capa responde a una pregunta que el equipo recibirá bajo auditoría. Saltarse alguna se convierte en una emergencia un año después.
- 01
Modelar la membership como tabla de join
Una tabla `members` con `(tenant_id, user_id, role)` es la única fuente de verdad sobre quién pertenece a dónde. Un usuario puede estar en muchos tenants; un tenant tiene muchos miembros. Cada otra tabla delega la autorización a una subquery contra `members`.
- 02
Aplicar el aislamiento con row-level security en SQL
Cada tabla multi-tenant tiene RLS activo y una policy que delega en `members`. Un check de autorización renombrado en el código aplicativo no esquiva la policy porque la policy corre en cada query, sin importar quién la escribió.
- 03
Trazar el tenant activo en la sesión
Un usuario con varios tenants necesita un "tenant activo" explícito en la sesión. Lo guardamos como cookie legible por el servidor escrita por una Server Action; el middleware lo expone como header; el client Supabase lo lee y acota las queries.
- 04
Construir el flujo de invitación
Una invitación es una fila `members` con `status = 'pending'`. El usuario invitado recibe un email con un enlace firmado; aceptar pasa el status a `active`. La invitación caduca; el audit log registra quién invitó a quién.
- 05
Aislar el billing por tenant
Cada tenant tiene su propio Stripe customer ID. Un pago fallido para el tenant A nunca afecta al tenant B. El Customer Portal abre solo para el tenant activo. Los informes de billing por tenant aparecen en el dashboard de admin.
- 06
Audit log desde el primer día
Cada acción privilegiada escribe una fila en `audit_events` (tenant, actor, acción, recurso, snapshot before-after). El log es append-only a nivel de policy. El primer auditor lo pide; nosotros lo entregamos sin reconstruir la traza de datos.
Qué entregamos
Tablas tenants y members
Tablas Postgres con foreign keys, índices, enum de rol, timestamps de joined-at e invited-at. El join de membership es la única fuente de verdad.
Policies de row-level security
Cada tabla multi-tenant tiene RLS activado. Las policies delegan cada lectura, escritura, update y delete a una subquery contra `members`. Guardadas en migrations bajo source control.
Cookie de tenant activo + header del middleware
Una cookie firmada lleva el ID del tenant activo; el middleware la lee y expone `x-tenant-id` a la aplicación. El client de Supabase lo recoge en automático.
Flujo de invitación
Server Actions para invitar, listar invitaciones pendientes, aceptar, rechazar y revocar. Email enviado vía Resend con un token firmado; expiración gestionada a nivel de base de datos.
Permisos role-based
Roles (owner, admin, member, billing) definidos como enum Postgres. Las policies comprueban el rol contra la acción. La aplicación lee el rol; la base de datos lo aplica.
Customer Stripe por tenant
Un solo Stripe customer ID por tenant, guardado en la fila tenants. Subscription, facturas, Customer Portal: todo acotado al tenant activo. Las acciones de billing cross-tenant son físicamente imposibles.
Audit log
Tabla `audit_events` append-only con policy que bloquea updates y deletes. Cada Server Action escribe una fila; el dashboard de admin muestra el log por tenant.
UI de switcher de tenant
Un dropdown en el header que lista los tenants del usuario y cambia el tenant activo vía Server Action. Valida la membership antes de cambiar la cookie.
Feature flags por tenant
Entradas de Edge Config indexadas por ID de tenant, leídas en el edge en milisegundos de un dígito. Usadas para rollouts graduales, A/B tests y features de tier enterprise.
Borrado de datos GDPR por tenant
Un procedimiento documentado para el borrado completo del tenant: borrado del Stripe customer, limpieza del file storage y retención del audit log según el reglamento.
Suspensión del tenant
Una columna `status` en la fila tenants con valores como `active`, `suspended`, `cancelled`. El middleware aplica el status; los tenants suspendidos ven un paywall, no una app a medias.
Migración desde single-tenant
Cuando la aplicación nació single-tenant, la migración crea un tenant por defecto, hace backfill de la membership, activa RLS gradualmente y termina con un guard de CI que falla si una nueva tabla no tiene RLS.
Contexto del tenant activo y flujo de invitación
Dos patrones que sostienen la arquitectura multi-tenant. La cookie del tenant activo la escribe una Server Action y la lee el middleware. El flujo de invitación es una Server Action que crea el miembro pendiente, manda el email y devuelve la URL que el invitado va a clicar.
La arquitectura SaaS multi-tenant se apoya en dos patrones que el código aplicativo se lleva consigo: el contexto del tenant activo y el flujo de invitación. Los dos archivos de abajo son los que enviamos; junto con las policies de row-level security de la página stack de Supabase, cubren la superficie multi-tenant que la aplicación toca de verdad.
1. La cookie del tenant activo y el middleware
Una cookie firmada lleva el ID del tenant activo. El middleware la lee en cada petición, valida que el usuario sea miembro de ese tenant y expone x-tenant-id como header que el código aplicativo (y el client Supabase) leen.
// src/middleware.ts (extracto)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyTenantCookie } from '@/lib/auth/active-tenant'
export async function middleware(request: NextRequest) {
const session = request.cookies.get('session')?.value
const tenantCookie = request.cookies.get('active_tenant')?.value
if (!session) return NextResponse.next()
const activeTenantId = await verifyTenantCookie({
session,
tenantCookie,
})
const requestHeaders = new Headers(request.headers)
if (activeTenantId) {
requestHeaders.set('x-tenant-id', activeTenantId)
}
return NextResponse.next({ request: { headers: requestHeaders } })
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// src/lib/auth/active-tenant.ts
import { jwtVerify, SignJWT } from 'jose'
import { adminClient } from '@/lib/supabase/admin'
const TENANT_COOKIE_SECRET = new TextEncoder().encode(
process.env.TENANT_COOKIE_SECRET!,
)
export async function verifyTenantCookie(opts: {
session: string
tenantCookie: string | undefined
}): Promise<string | null> {
if (!opts.tenantCookie) return null
// Verifica que la cookie esté firmada por nosotros y lleve un vínculo
// user-tenant.
let payload
try {
const verified = await jwtVerify(opts.tenantCookie, TENANT_COOKIE_SECRET)
payload = verified.payload as { userId: string; tenantId: string }
} catch {
return null
}
// Verifica que el usuario siga siendo miembro del tenant.
const { data } = await adminClient
.from('members')
.select('tenant_id')
.eq('user_id', payload.userId)
.eq('tenant_id', payload.tenantId)
.eq('status', 'active')
.maybeSingle()
return data?.tenant_id ?? null
}
export async function signTenantCookie(opts: {
userId: string
tenantId: string
}): Promise<string> {
return new SignJWT({ userId: opts.userId, tenantId: opts.tenantId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(TENANT_COOKIE_SECRET)
}
2. La Server Action para cambiar tenant
Una Server Action valida la membership y escribe la cookie firmada. El dropdown de la UI la llama cuando el usuario elige otro tenant; la siguiente petición opera dentro del nuevo contexto de tenant.
// app/[lang]/(app)/actions/switch-tenant.ts
'use server'
import { z } from 'zod'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { signTenantCookie } from '@/lib/auth/active-tenant'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
tenantId: z.string().uuid(),
})
export async function switchActiveTenant(
raw: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Verifica que el usuario sea miembro del tenant destino.
const { data: membership } = await adminClient
.from('members')
.select('tenant_id')
.eq('user_id', session.userId)
.eq('tenant_id', parsed.data.tenantId)
.eq('status', 'active')
.maybeSingle()
if (!membership) return { ok: false, error: 'not a member' }
const cookie = await signTenantCookie({
userId: session.userId,
tenantId: parsed.data.tenantId,
})
const cookieStore = await cookies()
cookieStore.set('active_tenant', cookie, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
path: '/',
})
revalidatePath('/', 'layout')
return { ok: true }
}
3. El flujo de invitación
Invitar a un miembro es una Server Action: crea una fila pendiente, manda el email, devuelve success. El token de invitación es el ID de la fila pendiente, firmado con TTL corto.
// app/[lang]/(app)/team/invite/actions.ts
'use server'
import { z } from 'zod'
import { SignJWT } from 'jose'
import { Resend } from 'resend'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'
import { getActiveTenantId } from '@/lib/auth/server'
const Input = z.object({
email: z.string().email(),
role: z.enum(['owner', 'admin', 'member', 'billing']),
})
const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)
const resend = new Resend(process.env.RESEND_API_KEY!)
export async function inviteMember(
raw: unknown,
): Promise<{ ok: true; inviteId: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const tenantId = await getActiveTenantId()
if (!tenantId) return { ok: false, error: 'no active tenant' }
// Solo owners y admins pueden invitar.
const { data: actor } = await adminClient
.from('members')
.select('role')
.eq('user_id', session.userId)
.eq('tenant_id', tenantId)
.single()
if (!actor || !['owner', 'admin'].includes(actor.role)) {
return { ok: false, error: 'permission denied' }
}
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Crea la fila pending member.
const { data: invite, error: insertError } = await adminClient
.from('members')
.insert({
tenant_id: tenantId,
email: parsed.data.email,
role: parsed.data.role,
status: 'pending',
invited_by: session.userId,
invited_at: new Date().toISOString(),
})
.select('id')
.single()
if (insertError) return { ok: false, error: insertError.message }
// Firma un token de corta duración que referencia el invite ID.
const token = await new SignJWT({ inviteId: invite.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(INVITE_SECRET)
// Registra el evento de auditoría.
await adminClient.from('audit_events').insert({
tenant_id: tenantId,
actor_user_id: session.userId,
action: 'member.invited',
resource_id: invite.id,
metadata: { email: parsed.data.email, role: parsed.data.role },
})
// Manda el email.
await resend.emails.send({
from: 'team@adamarant.com',
to: parsed.data.email,
subject: 'You are invited',
html: renderInviteEmail({
acceptUrl: `${process.env.SITE_URL}/invite/accept?token=${token}`,
}),
})
revalidatePath('/team')
return { ok: true, inviteId: invite.id }
}
function renderInviteEmail(opts: { acceptUrl: string }): string {
return `<p>You have been invited to join a team.</p>
<p><a href="${opts.acceptUrl}">Accept invitation</a></p>
<p>The link expires in seven days.</p>`
}
4. El handler de aceptación de la invitación
Cuando el usuario invitado clica el enlace del email, el handler verifica el token, vincula al usuario existente (o pide el signup), cambia la fila pendiente a active y escribe el evento de auditoría.
// app/[lang]/(public)/invite/accept/actions.ts
'use server'
import { jwtVerify } from 'jose'
import { redirect } from 'next/navigation'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'
const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)
export async function acceptInvite(token: string): Promise<void> {
let payload
try {
const verified = await jwtVerify(token, INVITE_SECRET)
payload = verified.payload as { inviteId: string }
} catch {
redirect('/invite/expired')
}
const session = await getServerSession()
if (!session) {
redirect(`/login?return=/invite/accept?token=${token}`)
}
// Cambia la fila pending member a active, vinculando el user ID.
const { data: invite, error } = await adminClient
.from('members')
.update({
user_id: session.userId,
status: 'active',
joined_at: new Date().toISOString(),
})
.eq('id', payload.inviteId)
.eq('status', 'pending')
.select('tenant_id, role')
.single()
if (error || !invite) redirect('/invite/expired')
await adminClient.from('audit_events').insert({
tenant_id: invite.tenant_id,
actor_user_id: session.userId,
action: 'member.joined',
resource_id: payload.inviteId,
metadata: { role: invite.role },
})
redirect('/dashboard')
}
5. Cómo encaja todo esto
La cookie del tenant activo acota cada petición. El middleware la expone como header. El client Supabase lee el header y acota sus queries. El flujo de invitación usa la misma tabla members en la que se apoyan las policies RLS, así que un nuevo miembro que acepta la invitación queda inmediatamente autorizado a leer los datos del tenant sin ningún update en el código aplicativo.
La arquitectura tiene tres componentes: el join de membership, las policies de row-level security, el contexto del tenant activo. Una vez que esos tres viven en source control, el código aplicativo deja de preguntarse "¿este usuario está autorizado a ver esta fila?" y empieza a preguntarse "¿cuál es la consulta más útil que puedo escribir hoy?". Y esa es la ganancia de productividad que se obtiene poniendo la autorización dentro de la base de datos.
Stacks relacionados
Preguntas frecuentes
¿Cuándo tiene sentido hacer un SaaS multi-tenant?
Desde el principio, si hay cualquier posibilidad de que un segundo cliente se sume al producto. Poner a posteriori los bordes de tenant sobre un codebase single-tenant cuesta el doble del trabajo que construirlos desde el primer día. Diseñamos cada nuevo SaaS como multi-tenant, a menos que el cliente lo descarte explícitamente (raro; suele ser una herramienta interna de nicho).
¿Un tenant por Stripe customer, o un Stripe customer por usuario?
Un Stripe customer por tenant. El billing sigue al equipo, no al individuo. Cuando un tenant tiene varios miembros, el rol de billing decide quién puede gestionar el billing; el Customer Portal abre solo para el tenant activo. Es también la única forma de manejar limpias las migraciones de plan cuando cambia la composición del equipo.
¿Cómo migráis una app single-tenant a multi-tenant?
Se crea un tenant por defecto, se hace backfill de la membership para que cada usuario existente se una a él, se activa RLS en cada tabla multi-tenant tras un feature flag, se corre una validación dual-track en staging, se flipea el flag en producción. La migración dura uno a tres días en un codebase pequeño; los más grandes escalan con el número de tablas que necesitan RLS.
¿Los usuarios pueden pertenecer a varios tenants?
Sí. La tabla `members` es un join, así que el mismo usuario puede ser miembro de muchos tenants con roles potencialmente distintos en cada uno. La UI lleva un switcher de tenant activo; el código aplicativo lee el tenant activo desde la sesión, no desde el usuario.
¿Cómo rinde row-level security a escala?
Una policy bien indexada añade uno o dos milisegundos por query. El patrón estándar (subquery contra `members` indexada por `user_id`) necesita un índice en `members(user_id)`; lo añadimos en el schema. El análisis de queries lentas con `explain analyze` atrapa el caso raro donde una policy está haciendo demasiado trabajo.
¿Y las escrituras con service-role que bypasean RLS?
Las claves service-role bypasean RLS por diseño (los scripts de migration, los handlers de webhook, los cron jobs lo necesitan). Restringimos la service-role key al código del lado del servidor vía la configuración de build, y auditamos cada escritura service-role en el audit log. El código aplicativo nunca ve la service-role key.
¿Cómo cumple el audit log con los requisitos de compliance?
Append-only vía una policy RLS que bloquea updates y deletes para cualquiera salvo el sistema. Cada Server Action privilegiada escribe una fila antes de devolver. Los reglamentos estándar (SOC 2, ISO 27001, GDPR Artículo 30) quieren una traza inmutable; la tabla es consultable por el admin del tenant, exportable por el cliente.
Cuéntanos tu SaaS multi-tenant
Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. Multi-tenant greenfield en cuatro a ocho semanas; migración desde single-tenant scopada aparte.