4dim / Notas

Postgres como última línea de defensa

Lo que la base no permite, no lo permite ningún camino, incluido el que todavía no existe. Un UNIQUE mal puesto que mezcló conversaciones, el dueño que no se borra, permisos por columna que blindan el pasado, y la restricción que destapó un dato inventado.

La disciplina no es una salvaguarda

Esta nota es para quien decide de qué se fía dentro de un sistema. La idea corta: las reglas que no pueden fallar no se guardan en el código, se guardan en la base de datos.

Casi toda la seguridad de una aplicación vive en su código: el filtro que comprueba el permiso, el if que impide borrar al dueño, la función que valida antes de guardar. Y casi todos los fallos graves vienen de que un camino nuevo se saltó ese código. Una migración de datos. Un script de soporte. Una dirección que alguien añadió sin recordar la regla.

La base de datos, en cambio, ve todos los caminos. Lo que la base no permite, no lo permite ninguno, incluido el que todavía no existe. Por eso las reglas que no pueden fallar las escribimos dos veces: en el código, para dar un buen mensaje, y en Postgres, para que sean ciertas.

Un UNIQUE mal puesto mezcló conversaciones

Es el ejemplo de que la restricción manda, para bien y para mal. Una restricción es una regla escrita en la tabla misma, que la base aplica siempre y sin excepción.

Nuestra tabla de conexiones tenía UNIQUE (negocio, tipo_de_canal): un negocio, un WhatsApp. Impedía tener dos números, Ventas y Posventa. Y había algo peor. El código que abría conversaciones elegía la conexión por tipo de canal, así que cuando se forzó un segundo número, un mensaje que llegaba a Ventas se enganchaba al hilo de Posventa. En silencio.

La restricción correcta es UNIQUE por el identificador real del número, con un índice parcial que permite una sola conexión «sin número» por canal mientras se configura. Con esa restricción bien puesta, un INSERT … ON CONFLICT hace que reconectar el mismo número con una credencial nueva actualice la fila, y que un número distinto cree una bandeja nueva. La identidad la define la base, y el código la usa.

El dueño no se borra: ON DELETE RESTRICT

El dueño de un negocio se distinguía por tener el rol «Cliente». Si se le daba ese mismo rol a otra persona, las dos podían echarse mutuamente.

Ahora hay una columna owner_user_id en el negocio, una sola por negocio, y ni alguien con permiso de gestionar el equipo puede tocar al propietario. La comprobación es por identidad y no por permiso, porque cada negocio puede inventarse los roles que quiera. Y el vínculo con la fila del usuario lleva ON DELETE RESTRICT: si la interfaz fallara y alguien intentara borrar al dueño, la base se niega.

Permisos por columna: el pasado, blindado

En nuestra base de conocimiento, cada afirmación del grafo tiene una fecha en que se sostuvo y otra en que se retiró. Nunca se edita ni se borra: corregir es retirar la fila vieja e insertar una nueva. Esa regla se rompe con un solo UPDATE en el sitio equivocado, y confiar en que nadie lo haga no es una salvaguarda.

Así que el usuario con el que se conecta la aplicación tiene GRANT UPDATE (retirado_en) sobre esa tabla. Es permiso para actualizar esa columna y ninguna otra, y ningún DELETE. Ni un error de programación ni una inyección SQL que llegara a ejecutarse con esa credencial podrían reescribir la historia, porque la credencial no puede. Postgres reparte permisos por columna, y casi nadie lo usa.

Lo que la aplicación «no está programada para hacer» lo hará algún día por error. Lo que su credencial no puede hacer, no.

Tres detalles que costaron un fallo cada uno

  • El plazo vive en el DEFAULT de la columna. Las invitaciones caducan a los siete días. Ese plazo estaba en el código (now() + ($6 || ' days')::interval, que además hace que Postgres dude del tipo del operador y falle con «operator is not unique») y duplicado en la tabla. Dos copias acaban separándose. Ahora decide solo la tabla, y la constante del código existe únicamente para mostrarla.
  • ALTER DEFAULT PRIVILEGES. Los GRANT, que son los permisos que se dan sobre una tabla, no se heredan. Una tabla creada por una migración posterior nace invisible para el usuario de la aplicación, y el fallo aparece en producción. Se fijan los privilegios por defecto una vez, y las tablas futuras los heredan.
  • SECURITY DEFINER con search_path fijo. Una tabla partida por meses necesita que alguien cree la parte del mes siguiente, y el usuario de la aplicación no puede. Lo hace una función que corre con los privilegios del dueño, y lleva el search_path fijado para que nadie le cuele una tabla con el mismo nombre.

La restricción que destapó un dato inventado

Exigimos que toda retirada de una afirmación llevara su propia cita. La regla quedó escrita en la tabla: retirado_en nulo si y solo si cita_retirada_id nulo. Y la migración falló.

Había una retirada sin fuente. Una fecha, 1931, que el autor había puesto de memoria para cuándo se abandonó cierta tesis. No había documento que la respaldara. La restricción no la aceptó, y la fila se revirtió.

Esa es la razón de poner las reglas en la base. No para castigar al código, sino para que un dato que no se puede demostrar no se pueda guardar, venga de donde venga. Incluido de nosotros.

Qué hacer entonces

  • Escribir cada regla que no puede fallar como restricción: UNIQUE, CHECK, NOT NULL, ON DELETE RESTRICT.
  • Dar al usuario de la aplicación solo lo que necesita, por tabla y, donde importe, por columna.
  • Fijar privilegios por defecto para que las tablas futuras nazcan con permisos.
  • Poner los valores de negocio (plazos, topes) en un solo sitio, y que sea la tabla.
  • Cuando una restricción nueva falle sobre datos viejos, no relajarla: mirar el dato.

Esta nota sale de nuestro trabajo en Software a medida.

← todas las notas