02Bases de dades5 min

Per què he tret RLS de Postgres les dues vegades que l'he fet servir

Row Level Security sembla la pràctica correcta per defecte. Amb Drizzle, server actions i auth pròpia, la lògica de permisos ja viu en un altre lloc i mantenir-ne les dues dol.

Per Salva Castells

Imatge de portada: Paulo Humberto, CC BY-SA 3.0.
En aquest post

Al meu esquema de perfils hi havia una política així, escrita amb el helper de Drizzle per a Neon:

crudPolicy({
  role: authenticatedRole,
  read: authUid(table.userId),
  modify: false,
})

Tres línies. Diuen que un usuari autenticat només veu les seves files i no les pot modificar. És difícil discutir-hi. Un any i mig després vaig esborrar aquelles tres línies, i les de la taula de feedback, en el mateix commit. Mesos més tard vaig repetir l'esborrat en un altre projecte que havia heretat el mateix esquema.

No va ser un caprici. Va ser que la política i el codi deien el mateix, i quan dos llocs diuen el mateix un dels dos acaba mentint.

La promesa d'RLS i per què la vaig comprar

La promesa és bona: el permís viu a la base de dades, així que tant se val qui consulti. Si demà escric un script de manteniment, o obro un endpoint nou, o algú es deixa un where sense posar, la fila no surt. És defensa en profunditat de debò, no un adorn.

Vaig muntar-ho tot al voltant d'això. Dues connexions:

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 connexió "autenticada" que porta el JWT de l'usuari i respecta les polítiques, i una altra d'admin amb la DATABASE_URL normal que se les salta. Fins i tot vaig arribar a cachejar un mapa de connexions RLS per id d'usuari, fins que em vaig adonar que estava desant connexions lligades a un token que caduca i ho vaig deixar en crear-ne una de nova cada vegada.

On va començar a grinyolar amb Drizzle i server actions

El problema no és que RLS falli. És que en una app amb Next i server actions gairebé mai no és la capa que decideix.

Cada acció de servidor ja comença resolent la sessió. Ja tens l'usuari abans de tocar la base de dades. I aleshores escrius això:

const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));

Aquell where és redundant amb la política. Però no el pots treure, perquè si el treus el codi queda mentint: sembla que porta tot el feedback i depèn d'una política invisible que és en un altre fitxer. Així que acabes amb el filtre al codi i a la base, i el dubte de quin dels dos és el que està funcionant.

La segona cosa és la fricció del dia a dia:

  • Dues connexions. Cada funció nova comença amb la pregunta de si va per RLS o per admin. I tot el que sigui administració, webhooks de pagament, crons o migracions va per admin. És a dir: pel camí que no comprova res.
  • Els errors no expliquen res. Quan la política et bloqueja no et diu "et falta el claim"; et torna zero files. Depurar un select que torna un array buit quan la fila existeix costa més del que sembla.
  • El token viatja on no voldries. La URL autenticada acaba en una variable amb prefix NEXT_PUBLIC_, i això ja obliga a pensar dues vegades què exposes.
  • Les polítiques viuen a l'snapshot. Canviar una política és generar migració i aplicar-la. Canviar una comprovació en codi és canviar una línia.

De fet, en un moment em vaig saltar Drizzle sencer i vaig passar el feedback i els perfils a parlar per PostgREST, justament per recolzar-me de debò en RLS. Allà el preu es va veure clar: vaig perdre el tipatge, vaig haver d'escriure els noms de columna a mà en snake_case (user_id, created_at) i cada crida acabava en el mateix ritual.

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[] al final. És a dir, confiar que la base torna el que jo dic. Això va tornar a Drizzle dues setmanes després.

El mateix desenllaç al segon projecte

Quan vaig arrencar el projecte següent partint del mateix boilerplate, les polítiques venien a dins. Van durar el que vaig trigar a tocar la taula de feedback de debò. El commit en què les vaig treure feia alhora una altra cosa: reanomenar feedback.description a feedback.content. És un detall ximple però explica la història sencera: estava donant forma al model de dades, i les polítiques eren el que m'estorbava per moure'l. En aquell commit van desaparèixer l'import de drizzle-orm/neon i les quatre crides a crudPolicy. Les taules es van quedar amb l'única cosa que de debò es fa servir cada dia:

(table) => [index("idx_feedback_user_id").on(table.userId)]

Un índex per user_id. Això sí que ho consulta Postgres a cada petició.

On viu avui la meva lògica de permisos

Al middleware i a la capa d'accions de servidor. Quan vaig deixar l'auth gestionada i em vaig escriure el meu propi middleware de sessió, el lloc on es decideix qui veu què va quedar en un únic punt: es resol la sessió, es comprova el rol, i a partir d'aquí totes les consultes s'escriuen amb el userId ja a la mà. Una sola connexió, la d'admin, i cap camí privilegiat paral·lel.

És menys segur sobre el paper. Una consulta a la qual se li oblidi el where torna files d'un altre. Ho compenso amb que aquestes consultes viuen totes en un directori d'accions, no repartides per l'app, i amb que la signatura de la funció t'obliga a passar l'usuari.

Quan sí que tornaria a fer servir RLS

Quan la base de dades té més d'un client. Si a més de la meva aplicació hi ha un PostgREST públic, un client que s'hi connecta directe, o analistes amb accés de només lectura, la política és l'únic lloc on la regla es compleix per a tothom. Allà el cost de mantenir dues veritats es paga sol.

També en multi-tenant dur, on la fallada no és "veus un feedback aliè" sinó "veus les dades d'una altra empresa". Allà vull la xarxa a sota encara que el codi ja filtri.

Per a una aplicació d'un sol client, amb server actions i l'usuari resolt abans de la primera consulta, RLS és una segona còpia d'una regla que ja tens escrita. I una regla escrita dues vegades no és el doble de segura: és el doble de fàcil de deixar desactualitzada.