La mayoría de los tutoriales "SaaS multi-tenant sobre Supabase" te enseñan a añadir una columna tenant_id y a fingir que eso resuelve la autorización. No la resuelve. El patrón de abajo es el que pasamos a producción: una tabla de join members que tiene la relación tenant-usuario, y policies de row-level security que delegan cada chequeo de autorización en ella.
1. La forma de las tablas
Un usuario pertenece a varios tenants a través de una fila en members. Cada fila de members lleva el rol. Las demás tablas que necesitan acotación por tenant tienen un tenant_id y confían en que la policy lo aplique.
-- supabase/migrations/0001_tenants.sql
create table tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create type member_role as enum ('owner', 'admin', 'member', 'billing');
create table members (
tenant_id uuid not null references tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role member_role not null,
joined_at timestamptz not null default now(),
primary key (tenant_id, user_id)
);
create index members_user_idx on members(user_id);
create table tickets (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id) on delete cascade,
title text not null,
body text,
status text not null default 'open',
assignee_id uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index tickets_tenant_idx on tickets(tenant_id, status, created_at desc);
2. Las policies de row-level security
Las policies activan RLS sobre la tabla y definen qué se puede leer, escribir y actualizar. Cada policy delega en una subquery contra members, así una sola fuente de verdad controla cada decisión.
-- supabase/migrations/0002_tickets_rls.sql
alter table tickets enable row level security;
create policy "tickets: tenant members can read"
on tickets for select
using (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: tenant members can insert"
on tickets for insert
with check (
tenant_id in (select tenant_id from members where user_id = auth.uid())
);
create policy "tickets: admins can update"
on tickets for update
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role in ('owner', 'admin')
)
);
create policy "tickets: only owners can delete"
on tickets for delete
using (
tenant_id in (
select tenant_id from members
where user_id = auth.uid() and role = 'owner'
)
);
3. El Server Component de Next.js que lee los datos
El código de la aplicación deja de cargar con la autorización. El client (aquí un Server Component de Next.js) lee tickets como si el usuario pudiera verlo todo; la verdad la dice la base de datos.
// app/[lang]/(app)/tickets/page.tsx
import { createServerClient } from '@/lib/supabase/server'
export default async function TicketsPage() {
const supabase = await createServerClient()
const { data: tickets, error } = await supabase
.from('tickets')
.select('id, title, status, created_at')
.eq('status', 'open')
.order('created_at', { ascending: false })
if (error) throw error
if (!tickets) return null
return (
<ul>
{tickets.map((t) => (
<li key={t.id}>
<a href={`/tickets/${t.id}`}>{t.title}</a>
</li>
))}
</ul>
)
}
4. Los tipos generados
Corremos supabase gen types typescript --linked > src/types/db.ts después de cada migration. El archivo generado queda committeado; la CI falla si va en drift respecto al schema desplegado. La fila de tickets se convierte en un tipo Tables<'tickets'> que la aplicación usa en todas partes.
// src/lib/supabase/server.ts
import { cookies } from 'next/headers'
import { createServerClient as createSupabaseServerClient } from '@supabase/ssr'
import type { Database } from '@/types/db'
export async function createServerClient() {
const cookieStore = await cookies()
return createSupabaseServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
for (const { name, value, options } of toSet) {
cookieStore.set(name, value, options)
}
},
},
},
)
}
5. Qué te compra esto
Una columna nueva en tickets, un rol nuevo, una policy nueva: cada una es una sola migration SQL que pasa por review. El documento de autorización que te pedirá el siguiente buyer enterprise es un pantallazo del archivo de policies. El siguiente equipo que abra el codebase lee el mismo SQL que leemos nosotros, no una capa de abstracción custom.
La seguridad multi-tenant deja de ser una feature que envías; pasa a ser una propiedad de la base de datos.