Stack · Supabase
Lavoriamo su Supabase perché il modello di sicurezza è reale
Postgres con row-level security, Auth che va online in una settimana, Storage con signed URL, Realtime quando serve davvero. Senza lock-in, perché il database è soltanto Postgres.
Perché questo stack
- 01
Row-level security come livello di autorizzazione vero
La maggior parte delle app mette l'autorizzazione nel codice applicativo e poi scrive un documento separato che spiega perché il cliente A non vede i dati del cliente B. Con RLS la risposta è una policy SQL che blocca per default, gira su ogni query e di quella policy ne puoi fare uno screenshot nel security review.
- 02
Postgres, che sai già come portarti via
Il giorno in cui il progetto esce da Supabase, il database esce con il progetto. Lo abbiamo fatto. Niente lock-in a sorpresa, niente linguaggio di query proprietario, niente prezzo per riga che diventa insostenibile a scala. Solo Postgres.
- 03
Auth senza un servizio separato
Social provider, magic link, OTP, MFA, SSO, cookie server-side: tutto nello stesso progetto dei dati. Niente webhook da Auth0 al tuo database per tenere allineate due tabelle utenti.
- 04
Type safety generata dallo schema vivo
Generiamo i tipi TypeScript dallo schema deployato. Una colonna rinominata rompe la build prima che il codice arrivi in produzione, non il report di un cliente che paga.
- 05
Storage, Realtime, Edge Functions nello stesso posto
Un solo progetto gestisce allegati con signed URL, aggiornamenti in tempo reale via WebSocket e ingestione di webhook in edge. Tre servizi separati collassano in uno e il team impara un solo modello mentale.
Cosa sviluppiamo con questa tecnologia
- 01
Policy di row-level security, verificate e versionate
Le policy vivono nelle migration sotto source control, non nella UI del database. Ogni modifica passa per review e ha un test automatico.
- 02
Modello dati multi-tenant
Tabella members con enum dei ruoli (owner / admin / member / billing), policy che limitano ogni lettura e scrittura al tenant attivo.
- 03
Auth con social provider e MFA
Google, GitHub, Apple, magic link, SSO via SAML dove serve al buyer. Enrolment, recovery code, gestione dispositivi.
- 04
Storage con signed URL e policy sui bucket
Bucket per-tenant, signed URL che scadono, trasformazioni immagini in edge, RLS sugli oggetti di storage.
- 05
Sottoscrizioni Realtime dove servono davvero
Aggiornamenti live via WebSocket per inbox, dashboard, presence indicator. Filtrate da RLS, così un client vede solo le righe che può leggere.
- 06
Edge Functions per l'ingestione di webhook
Stripe, Resend, webhook di terzi atterrano in edge, validano la firma e scrivono via service-role client.
- 07
Migration e seed versionati
Migration in source control con up/down, seed script che sopravvivono al reset del database, CI che le esegue ad ogni PR.
- 08
Pipeline di tipi TypeScript generati
Uno script npm rigenera i tipi del database dopo ogni migration. La build fallisce se i tipi sono stantii.
- 09
Backup, point-in-time recovery, prova di restore
PITR attivo, cadenza di dump off-site, una prova di restore documentata che riesci davvero a fare prima del giorno in cui ti serve.
- 10
Blueprint per il self-hosting
Quando l'ambiente di procurement richiede on-premise, consegniamo un docker-compose che rispecchia lo stack cloud e un runbook che il prossimo team riesce a gestire.
- 11
Performance: indici, plan, budget delle query
Ogni query frequente ha un indice, un piano misurato e un budget. Il log delle query lente finisce nella dashboard.
- 12
Migrazione da Firebase, PlanetScale, Postgres nudo
Dati esistenti spostati, foreign key ricostruite, utenti auth trasferiti con migrazione hashed-password dove supportata.
Autorizzazione multi-tenant con row-level security, in codice
Autorizzazione multi-tenant con row-level security, in codice
La tabella members è l'unica fonte di verità su chi appartiene a quale tenant. Tutte le altre tabelle si appoggiano a questa via RLS. Un utente che cambia tenant cambia il risultato della policy senza un deploy di codice.
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.
Domande frequenti
Perché Supabase invece di Postgres nudo su RDS?+
Il Postgres è lo stesso. Quello che risparmi sono i mesi per creare Auth, Storage, Realtime e il livello operativo intorno. Se hai un team DBA interno e un team Devex, Postgres nudo va bene. La maggior parte dei team non li ha.
Self-hosted o Supabase Cloud?+
Cloud di default. Self-host quando il buyer richiede on-premise (settore pubblico, finanza regolata, certi settori sanitari). Il codice applicativo non cambia tra i due, cambia solo la connection string.
Migrate da Firebase o PlanetScale?+
Sì. Firebase è la richiesta più comune. Utenti, custom claim, documenti Firestore e oggetti Storage si mappano tutti su Postgres + RLS + bucket Storage. La migrazione si pianifica come progetto a sé; il costo dipende dalla forma dei dati e dal modello di auth.
Come si comporta row-level security a scala?+
Una policy ben indicizzata aggiunge uno-due millisecondi per query. Una policy mal scritta scansiona tutta la tabella. Misuriamo ogni policy con `explain analyze` prima del rilascio e aggiungiamo indici che combaciano con il predicato della policy.
Backup e disaster recovery?+
Point-in-time recovery a qualsiasi secondo degli ultimi sette giorni su Cloud, finestre più lunghe sui piani enterprise. Dump off-site su un bucket che possiedi tu. Una prova di restore documentata che facciamo con te una volta prima del lancio.
Come gestite i tipi generati quando lo schema cambia?+
Uno script npm rigenera i tipi dallo schema deployato. La CI lo esegue ad ogni PR; se i tipi generati divergono da quelli committati, la build fallisce. Niente bugie silenziose sui tipi.
Possiamo usare Supabase Auth con il nostro identity provider?+
Sì. SAML SSO per buyer enterprise, OIDC per identità federate, JWT custom per sistemi legacy. Gli utenti esistenti migrano via admin API mantenendo gli ID originali.