Un'architettura multi-tenant che sopravvive al primo security review enterprise
Membership attraverso una tabella di join tipizzata, isolamento applicato da row-level security in SQL, customer Stripe per tenant, audit log dal primo giorno, sospensione del tenant e cancellazione GDPR che funzionano davvero. L'abbiamo costruita perché, quando arriva la domanda "come garantite che il cliente A non veda i dati del cliente B", la risposta sta in una sola schermata di codice.
Il problema
Quasi tutti i SaaS infilano la multi-tenancy nel livello sbagliato
Il pattern che si rompe sotto audit è quello in cui l'autorizzazione vive nel codice applicativo. Un ingegnere junior aggiunge una route che dimentica lo scope del tenant; un refactor sposta un helper in un posto dove il tenant non è nel contesto; un'integrazione di terzi scrive sulla riga sbagliata. La risposta giusta è mettere il confine del tenant nel database, dove non può essere dimenticato. La tabella members è la legge; row-level security è l'applicazione; il codice applicativo smette di portarsi dietro la domanda. Lo progettiamo dall'inizio perché farlo a posteriori dopo il primo contratto enterprise costa il doppio.
Come lo affrontiamo
I sei livelli di un'architettura multi-tenant
Ogni livello risponde a una domanda che il team riceverà sotto audit. Saltarne uno si trasforma in un'emergenza un anno dopo.
- 01
Modellare la membership come tabella di join
Una tabella `members` con `(tenant_id, user_id, role)` è l'unica fonte di verità su chi appartiene a dove. Un utente può stare in molti tenant; un tenant ha molti membri. Ogni altra tabella delega l'autorizzazione a una subquery contro `members`.
- 02
Applicare l'isolamento con row-level security in SQL
Ogni tabella multi-tenant ha RLS attivo e una policy che delega a `members`. Un check di autorizzazione rinominato nel codice applicativo non aggira la policy perché la policy gira su ogni query, indipendentemente da chi l'ha scritta.
- 03
Tracciare il tenant attivo nella sessione
Un utente con più tenant ha bisogno di un "tenant attivo" esplicito nella sessione. Lo salviamo come cookie leggibile dal server impostato da una Server Action; il middleware lo espone come header; il client Supabase lo legge e limita le query.
- 04
Costruire il flusso di invito
Un invito è una riga `members` con `status = 'pending'`. L'utente invitato riceve un'email con un link firmato; accettando lo status va su `active`. L'invito scade; l'audit log registra chi ha invitato chi.
- 05
Isolare il billing per tenant
Ogni tenant ha il proprio Stripe customer ID. Un pagamento fallito per il tenant A non si ripercuote mai sul tenant B. Il Customer Portal apre solo per il tenant attivo. I report di billing per tenant compaiono nella dashboard admin.
- 06
Audit log dal primo giorno
Ogni azione privilegiata scrive una riga in `audit_events` (tenant, attore, azione, risorsa, snapshot prima-dopo). Il log è append-only a livello di policy. Il primo auditor lo chiede; noi lo consegniamo senza dover ricostruire la traccia dei dati.
Cosa consegniamo
Tabelle tenants e members
Tabelle Postgres con foreign key, indici, enum di ruolo, timestamp di joined-at e invited-at. Il join della membership è l'unica fonte di verità.
Policy di row-level security
Ogni tabella multi-tenant ha RLS attivo. Le policy delegano ogni lettura, scrittura, update e delete a una subquery contro `members`. Salvate nelle migration sotto source control.
Cookie tenant attivo + header middleware
Un cookie firmato porta l'ID del tenant attivo; il middleware lo legge ed espone `x-tenant-id` all'applicazione. Il client Supabase lo recepisce in automatico.
Flusso di invito
Server Action per invitare, listare gli inviti pending, accettare, rifiutare e revocare. Email inviata via Resend con token firmato; scadenza gestita a livello di database.
Permessi role-based
Ruoli (owner, admin, member, billing) definiti come enum Postgres. Le policy controllano il ruolo contro l'azione. L'applicazione legge il ruolo; il database lo applica.
Customer Stripe per tenant
Un solo Stripe customer ID per tenant, salvato sulla riga tenants. Subscription, fatture, Customer Portal: tutto scopato sul tenant attivo. Azioni di billing cross-tenant sono fisicamente impossibili.
Audit log
Tabella `audit_events` append-only con policy che blocca update e delete. Ogni Server Action scrive una riga; la dashboard admin mostra il log per tenant.
UI di switcher del tenant
Un dropdown nell'header che lista i tenant dell'utente e cambia il tenant attivo via Server Action. Valida la membership prima di flippare il cookie.
Feature flag per tenant
Voci Edge Config indicizzate per ID di tenant, lette in edge in millisecondi a una cifra. Usate per rollout graduali, A/B test e feature di tier enterprise.
Cancellazione dati GDPR per tenant
Una procedura documentata per la cancellazione completa del tenant: cancellazione dello Stripe customer, pulizia dei file in storage e conservazione dell'audit log secondo regolamento.
Sospensione del tenant
Una colonna `status` sulla riga tenants con valori come `active`, `suspended`, `cancelled`. Il middleware applica lo status; i tenant sospesi vedono un paywall, non un'app a metà.
Migrazione da single-tenant
Quando l'applicazione è nata single-tenant, la migrazione crea un tenant di default, fa backfill della membership, attiva RLS in modo graduale e finisce con un guard CI che fallisce se una nuova tabella manca di RLS.
Contesto del tenant attivo e flusso di invito
Due pattern che tengono insieme l'architettura multi-tenant. Il cookie del tenant attivo è impostato da una Server Action e letto dal middleware. Il flusso di invito è una Server Action che crea il membro pending, manda l'email e ritorna l'URL che l'utente invitato cliccherà.
L'architettura SaaS multi-tenant si regge su due pattern che il codice applicativo si porta dietro: il contesto del tenant attivo e il flusso di invito. I due file qui sotto sono quello che rilasciamo; insieme alle policy di row-level security della pagina stack Supabase, coprono l'area multi-tenant che l'applicazione tocca davvero.
1. Il cookie del tenant attivo e il middleware
Un cookie firmato porta l'ID del tenant attivo. Il middleware lo legge ad ogni richiesta, valida che l'utente sia membro di quel tenant ed espone x-tenant-id come header che il codice applicativo (e il client Supabase) leggono.
// src/middleware.ts (estratto)
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 che il cookie sia firmato da noi e porti un legame 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 che l'utente sia ancora membro 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 per cambiare tenant
Una Server Action valida la membership e scrive il cookie firmato. Il dropdown della UI la chiama quando l'utente sceglie un altro tenant; la richiesta successiva opera dentro il nuovo contesto del 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 che l'utente sia membro del tenant di destinazione.
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. Il flusso di invito
Invitare un membro è una Server Action: crea una riga pending, manda l'email, ritorna success. Il token di invito è l'ID della riga pending, firmato con TTL breve.
// 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 owner e admin possono invitare.
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 riga 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 a breve durata che referenzia l'invite ID.
const token = await new SignJWT({ inviteId: invite.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(INVITE_SECRET)
// Registra l'evento nell'audit.
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 l'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. L'handler di accettazione dell'invito
Quando l'utente invitato clicca il link dell'email, l'handler verifica il token, linka l'utente esistente (o chiede il signup), flippa la riga pending in active e scrive l'evento di audit.
// 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}`)
}
// Flippa la riga pending member in active, attaccando l'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. Come si tiene insieme tutto questo
Il cookie del tenant attivo limita ogni richiesta. Il middleware lo espone come header. Il client Supabase legge l'header e limita le sue query. Il flusso di invito usa la stessa tabella members su cui si appoggiano le policy RLS, quindi un nuovo membro che accetta l'invito è subito autorizzato a leggere i dati del tenant senza alcun update nel codice applicativo.
L'architettura ha tre componenti: il join della membership, le policy di row-level security, il contesto del tenant attivo. Una volta che quei tre vivono in source control, il codice applicativo smette di chiedersi "questo utente è autorizzato a vedere questa riga" e inizia a chiedersi "qual è la query più utile che posso scrivere oggi". E quello è il guadagno di produttività che si ottiene mettendo l'autorizzazione dentro al database.
Stack correlati
Domande frequenti
Quando ha senso fare un SaaS multi-tenant?
Dall'inizio, se c'è una qualunque possibilità che un secondo cliente si unisca al prodotto. Mettere a posteriori i confini di tenant su un codebase single-tenant costa il doppio del lavoro di costruirli dal primo giorno. Progettiamo ogni nuovo SaaS come multi-tenant, a meno che il cliente non lo voglia esplicitamente evitare (raro; di solito un tool interno di nicchia).
Un tenant per Stripe customer, o uno Stripe customer per utente?
Uno Stripe customer per tenant. Il billing segue il team, non l'individuo. Quando un tenant ha più membri, il ruolo di billing decide chi può gestire il billing; il Customer Portal apre solo per il tenant attivo. È anche l'unico modo per gestire pulito le migrazioni di piano quando cambia la composizione del team.
Come migrate un'app single-tenant a multi-tenant?
Si crea un tenant di default, si fa backfill della membership così ogni utente esistente vi entra, si attiva RLS su ogni tabella multi-tenant dietro un feature flag, si fa una validazione su staging in dual-track, si flippa il flag in produzione. La migrazione dura uno-tre giorni per un codebase piccolo; quelli più grandi scalano col numero di tabelle che richiedono RLS.
Gli utenti possono appartenere a più tenant?
Sì. La tabella `members` è un join, quindi lo stesso utente può essere membro di molti tenant con ruoli potenzialmente diversi in ciascuno. La UI porta uno switcher del tenant attivo; il codice applicativo legge il tenant attivo dalla sessione, non dall'utente.
Come si comporta row-level security a scala?
Una policy ben indicizzata aggiunge uno-due millisecondi per query. Il pattern standard (subquery contro `members` indicizzata per `user_id`) ha bisogno di un indice su `members(user_id)`; lo aggiungiamo nello schema. L'analisi delle query lente con `explain analyze` intercetta il caso raro in cui una policy sta facendo troppo lavoro.
E le scritture con service-role che bypassano RLS?
Le chiavi service-role bypassano RLS by design (gli script di migration, gli handler webhook, i cron job hanno bisogno di quel privilegio). Restringiamo la service-role key al codice server-side via configurazione di build, e facciamo audit di ogni scrittura service-role nell'audit log. Il codice applicativo non vede mai la service-role key.
Come fa l'audit log a rispondere ai requisiti di compliance?
Append-only via una policy RLS che blocca update e delete per chiunque tranne il sistema. Ogni Server Action privilegiata scrive una riga prima di ritornare. I regolamenti standard (SOC 2, ISO 27001, GDPR Articolo 30) vogliono una traccia immutabile; la tabella è interrogabile dall'admin del tenant, esportabile dal cliente.
Raccontaci il tuo SaaS multi-tenant
Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. Multi-tenant greenfield in quattro-otto settimane; migrazione da single-tenant scopata separatamente.