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, non un database proprietario

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

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.

Modello dati multi-tenant

Tabella members con enum dei ruoli (owner / admin / member / billing), policy che limitano ogni lettura e scrittura al tenant attivo.

Auth con social provider e MFA

Google, GitHub, Apple, magic link, SSO via SAML dove serve al buyer. Enrolment, recovery code, gestione dispositivi.

Storage con signed URL e policy sui bucket

Bucket per-tenant, signed URL che scadono, trasformazioni immagini in edge, RLS sugli oggetti di storage.

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.

Edge Functions per l'ingestione di webhook

Stripe, Resend, webhook di terzi atterrano in edge, validano la firma e scrivono via service-role client.

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.

Pipeline di tipi TypeScript generati

Uno script npm rigenera i tipi del database dopo ogni migration. La build fallisce se i tipi sono stantii.

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.

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.

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, non in una inbox che nessuno apre.

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

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 scoping a pagamento; 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.

Raccontaci cosa stai costruendo su Supabase

Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. App RLS-first in tre-sei settimane.