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.