Nel mio schema dei profili c'era una policy così, scritta con l'helper di Drizzle per Neon:
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Tre righe. Dicono che un utente autenticato vede solo le sue righe e non può modificarle. È difficile discuterne. Un anno e mezzo dopo ho cancellato quelle tre righe, e quelle della tabella di feedback, nello stesso commit. Mesi più tardi ho ripetuto la cancellazione in un altro progetto che aveva ereditato lo stesso schema.
Non è stato un capriccio. È stato che la policy e il codice dicevano la stessa cosa, e quando due posti dicono la stessa cosa uno dei due finisce per mentire.
La promessa di RLS e perché l'ho comprata
La promessa è buona: il permesso vive nel database, quindi non importa chi interroga. Se domani scrivo uno script di manutenzione, o apro un endpoint nuovo, o qualcuno si dimentica un where, la riga non esce. È difesa in profondità vera, non un ornamento.
Ho montato tutto attorno a quello. Due connessioni:
export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
const authToken = (await user?.getAuthJson())?.accessToken;
const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
const sql = neon(url, { authToken });
return drizzle(sql);
};
Una connessione "autenticata" che porta il JWT dell'utente e rispetta le policy, e un'altra da admin con la DATABASE_URL normale che se le salta. Ero persino arrivato a mettere in cache una mappa di connessioni RLS per id utente, finché non mi sono accorto che stavo conservando connessioni legate a un token che scade e sono passato a crearne una nuova ogni volta.
Dove ha iniziato a stridere con Drizzle e le server action
Il problema non è che RLS fallisca. È che in un'app con Next e le server action non è quasi mai il livello che decide.
Ogni server action inizia già risolvendo la sessione. Hai già l'utente prima di toccare il database. E allora scrivi questo:
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Quel where è ridondante con la policy. Ma non lo puoi togliere, perché se lo togli il codice resta lì a mentire: sembra che porti tutto il feedback e dipende da una policy invisibile che sta in un altro file. Così finisci con il filtro nel codice e nel database, e il dubbio su quale dei due stia davvero funzionando.
La seconda cosa è l'attrito quotidiano:
- Due connessioni. Ogni funzione nuova inizia con la domanda se va per RLS o per admin. E tutto quello che è amministrazione, webhook di pagamento, cron o migrazioni va per admin. Cioè: per la strada che non controlla niente.
- Gli errori non spiegano niente. Quando la policy ti blocca non ti dice "ti manca il claim"; ti restituisce zero righe. Fare il debug di un
selectche restituisce un array vuoto quando la riga esiste costa più di quanto sembri. - Il token viaggia dove non vorresti. La URL autenticata finisce in una variabile con prefisso
NEXT_PUBLIC_, e questo ti obbliga già a pensarci due volte su cosa esponi. - Le policy vivono nello snapshot. Cambiare una policy vuol dire generare una migrazione e applicarla. Cambiare un controllo nel codice è cambiare una riga.
Anzi, a un certo punto ho saltato del tutto Drizzle e ho fatto parlare feedback e profili via PostgREST, proprio per appoggiarmi davvero su RLS. Lì il prezzo si è visto chiaro: ho perso la tipizzazione, ho dovuto scrivere i nomi delle colonne a mano in snake_case (user_id, created_at) e ogni chiamata finiva nello stesso rituale.
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
Un as Feedback[] alla fine. Cioè fidarsi che il database restituisca quello che dico io. Quella cosa è tornata a Drizzle due settimane dopo.
Lo stesso finale nel secondo progetto
Quando ho avviato il progetto successivo partendo dallo stesso boilerplate, le policy erano lì dentro. Sono durate il tempo che ho messo a toccare la tabella di feedback sul serio. Il commit in cui le ho tolte faceva anche un'altra cosa: rinominare feedback.description in feedback.content. È un dettaglio sciocco ma racconta tutta la storia: stavo dando forma al modello dei dati, e le policy erano quello che mi ostacolava nel muoverlo. In quel commit sono spariti l'import di drizzle-orm/neon e le quattro chiamate a crudPolicy. Le tabelle sono rimaste con l'unica cosa che si usa davvero tutti i giorni:
(table) => [index("idx_feedback_user_id").on(table.userId)]
Un indice per user_id. Quello sì che Postgres lo consulta a ogni richiesta.
Dove vive oggi la mia logica dei permessi
Nel middleware e nel livello delle server action. Quando ho lasciato l'auth gestita e mi sono scritto il mio middleware di sessione, il posto dove si decide chi vede cosa è rimasto un punto unico: si risolve la sessione, si controlla il ruolo, e da lì in poi tutte le query si scrivono con lo userId già in mano. Una sola connessione, quella admin, e nessuna strada privilegiata parallela.
Sulla carta è meno sicuro. Una query che si dimentica il where restituisce righe di un altro. Lo compenso col fatto che quelle query vivono tutte in una directory di azioni, non sparse per l'app, e col fatto che la firma della funzione ti obbliga a passare l'utente.
Quando invece tornerei a usare RLS
Quando il database ha più di un client. Se oltre alla mia applicazione c'è un PostgREST pubblico, un client che si connette diretto, o analisti con accesso in sola lettura, la policy è l'unico posto dove la regola vale per tutti. Lì il costo di mantenere due verità si ripaga da solo.
Anche in multi-tenant duro, dove il guasto non è "vedi un feedback altrui" ma "vedi i dati di un'altra azienda". Lì voglio la rete sotto anche se il codice filtra già.
Per un'applicazione con un solo client, con server action e l'utente risolto prima della prima query, RLS è una seconda copia di una regola che hai già scritto. E una regola scritta due volte non è il doppio più sicura: è il doppio più facile da lasciare non aggiornata.
