Sé que este post va a sentar mal a alguien. RLS es de esas cosas que se recomiendan por defecto, como los backups o el HTTPS, y decir que lo has quitado suena a confesar que eres un vago. Así que empiezo por lo que tenía, para que se vea que no lo quité por no entenderlo.
En el esquema de perfiles había esto, con el helper de Drizzle para Neon:
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Un usuario autenticado lee sus filas y no modifica nada. Limpio. Y para que la política sirviera de algo, dos conexiones: una "autenticada" que lleva el JWT del usuario y pasa por las políticas, y otra de admin con la DATABASE_URL de siempre, que se las salta.
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);
};
Hubo una versión anterior que cacheaba un mapa de conexiones RLS por id de usuario. Me pareció una optimización lista durante unos días, hasta que caí en que estaba guardando conexiones atadas a un token que caduca. Lo dejé en crear una nueva en cada llamada y me olvidé del tema, que es lo que debería haber hecho desde el principio.
Año y medio después borré las políticas de perfiles y de feedback en el mismo commit. Meses más tarde volví a borrarlas en otro proyecto que había heredado el mismo esquema. Esto va de por qué.
Qué pasaba por cada conexión
La idea que me vendí al montarlo era la de defensa en profundidad: si un día escribo un script, abro un endpoint nuevo o se me olvida un where, la fila no sale porque la base de datos no la deja salir. Y eso es verdad. Lo que no me paré a pensar es qué código iba a ir por cada una de las dos conexiones.
Por la conexión RLS iban las server actions del usuario. Esas que empiezan resolviendo la sesión, que ya tienen el user en la mano antes de tocar la base, y que se escriben así:
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Por la de admin iba todo lo demás: el panel de administración, los webhooks de pago, los crons, las migraciones, cualquier script de mantenimiento. Justo las piezas que no tienen un usuario delante, las que tocan filas de mucha gente a la vez y las que escribes con prisa un viernes. O sea, que la red de seguridad estaba tendida debajo del código más aburrido y predecible de la app, y el código con más probabilidades de hacer una burrada iba por el camino que no comprueba nada.
No digo que eso invalide RLS. Digo que en mi caso la política protegía un sitio donde el fallo ya era improbable, y cuando piensas en "defensa en profundidad" te imaginas otra cosa.
El where que no puedes quitar
Lo que me acabó cansando en el día a día fue ese where de arriba. Es redundante con la política, y aun así no lo puedes borrar. Si lo quitas, el código se lee como "trae todo el feedback" y solo funciona bien porque hay una regla invisible en otro fichero. Nadie que abra esa función seis meses después va a adivinarlo. Así que el filtro se queda en los dos sitios, y cuando algo va raro no sabes cuál de los dos es el que está trabajando.
A eso súmale:
- Cada función nueva empezaba con la misma pregunta, ¿esto va por RLS o por admin? Y la respuesta segura ("por RLS") a veces no se podía dar, así que acababa en admin.
- Cuando la política te bloquea no hay error. Hay cero filas. Me pasé más de una tarde mirando un
selectque devolvía[]sabiendo que la fila estaba ahí, antes de acordarme de que el claim del token no era el que yo creía. - La URL autenticada vivía en una variable con prefijo
NEXT_PUBLIC_. No es un agujero por sí mismo, pero cada vez que la veía tenía que volver a razonar qué exponía y qué no, y eso es exactamente el tipo de cosa que un día razonas mal. - Tocar una política es generar una migración y aplicarla. Tocar una comprobación en código es cambiar una línea y ya.
El desvío por PostgREST
Aquí viene la parte de la que me río un poco ahora. En algún momento pensé que el problema era Drizzle: si iba a tener RLS, que fuera en serio. Pasé feedback y perfiles a hablar por PostgREST, que se apoya en las políticas sin pensar.
Duró dos semanas. Perdí el tipado, escribía los nombres de columna a mano en snake_case (user_id, created_at) y cada llamada terminaba igual:
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
Ese as Feedback[] final es la base de datos diciéndome "fíate de mí". Fíjate además en el .eq('user_id', user.id): había montado todo para apoyarme en la política y seguía filtrando a mano, por las mismas razones de antes. Volví a Drizzle con la cabeza un poco más baja.
El segundo borrado
En el siguiente proyecto las políticas venían dentro del boilerplate. Aguantaron hasta que me puse a trabajar la tabla de feedback en serio. El commit que las quitó hacía también otra cosa: renombrar feedback.description a feedback.content. Parece una casualidad y no lo es. Estaba moviendo el modelo de datos, y las políticas eran lo que más resistencia ponía a moverlo. Se fueron el import de drizzle-orm/neon y las cuatro llamadas a crudPolicy, y la tabla quedó con lo único que Postgres consulta de verdad en cada petición:
(table) => [index("idx_feedback_user_id").on(table.userId)]
Hoy los permisos viven en el middleware y en la capa de acciones. Cuando dejé la auth gestionada y escribí mi propio middleware de sesión, el sitio donde se decide quién ve qué quedó en un único punto. Una sola conexión, sin camino privilegiado paralelo que haya que recordar.
"Pues ahora un where olvidado te filtra datos ajenos"
Sí. Es el comentario que me haría yo mismo, y es correcto: sobre el papel esto es menos seguro. Lo que hago para compensar es poco y bastante mecánico. Todas las consultas con datos de usuario viven en un directorio de acciones, no esparcidas por componentes y rutas, así que revisarlas es abrir una carpeta. Y la firma de cada función pide el usuario de forma explícita, algo así (ejemplo simplificado):
export async function listFeedback(user: SessionUser) {
return db.select().from(feedback).where(eq(feedback.userId, user.id));
}
No es infalible. Nada impide que alguien reciba user y no lo use. Pero si el filtro falta, falta a la vista, en la misma función, y no escondido detrás de una política que alguien desactivó en una migración de hace tres meses.
Una prueba que haría antes de decidir
Si tienes RLS montado y dudas, haz un grep de cuántas llamadas van por la conexión con políticas y cuántas por la de admin, y mira qué hace cada grupo. Si lo que va por admin es lo delicado (pagos, borrados masivos, tareas programadas), la política te está dando menos de lo que crees.
Y la otra pregunta es cuántos clientes tiene tu base de datos. Si además de tu app hay un PostgREST público, alguien que se conecta directo, o analistas con acceso de lectura, entonces la política es el único sitio donde la regla se cumple para todos, y yo la mantendría sin pensarlo. Lo mismo en multi-tenant de verdad, donde el fallo no es ver un feedback ajeno sino los datos de otra empresa; ahí quiero la red aunque el código ya filtre y aunque me toque pagar el where duplicado.
Para una app con un solo cliente, server actions y el usuario resuelto antes de la primera consulta, yo no la vuelvo a poner. Aunque reconozco que cada vez que escribo una consulta nueva a mano hay una vocecita que me recuerda que antes había algo debajo.
