La maggior parte dei tutorial "SaaS multi-tenant su Supabase" ti fa aggiungere una colonna tenant_id e finge che questo risolva l'autorizzazione. Non la risolve. Il pattern qui sotto è quello che mandiamo in produzione: una tabella di join members che custodisce la relazione tenant-utente, e policy di row-level security che delegano a questa tabella ogni controllo di autorizzazione.
1. La forma delle tabelle
Un utente appartiene a molti tenant attraverso una riga members. Ogni riga members porta con sé il ruolo. Ogni altra tabella che ha bisogno di essere limitata al tenant ha un tenant_id e si fida che la policy lo applichi.
-- 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. Le policy di row-level security
Le policy attivano RLS sulla tabella e definiscono cosa è leggibile, scrivibile e modificabile. Ogni policy delega a una subquery su members, così una sola fonte di verità controlla ogni decisione.
-- 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. Il Server Component Next.js che legge i dati
Il codice applicativo smette di portarsi dietro l'autorizzazione. Il client (qui un Server Component Next.js) legge tickets come se l'utente potesse vedere tutto; è il database che dice la verità.
// 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. I tipi generati
Eseguiamo supabase gen types typescript --linked > src/types/db.ts dopo ogni migration. Il file generato viene committato; la CI fallisce se diverge rispetto allo schema deployato. La riga di tickets diventa un tipo Tables<'tickets'> che l'applicazione usa ovunque.
// 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. Cosa ti compra questo
Una colonna nuova in tickets, un ruolo nuovo, una policy nuova: ognuna è una singola SQL migration che passa per review. Il documento di autorizzazione che il prossimo buyer enterprise ti chiederà è uno screenshot del file delle policy. Il prossimo team che apre il codice legge la stessa SQL che leggiamo noi, non un livello di astrazione custom.
La sicurezza multi-tenant smette di essere una funzionalità da rilasciare; diventa una proprietà del database.