En resumen
Un control de permisos dentro de una función SECURITY DEFINER decía if not (has_perm or created_by = me or assignee_id = me) then raise. En junio de 2026, las 3683 tareas de la aplicación no tenían creador ni responsable. false OR NULL OR NULL es NULL, NOT NULL sigue siendo NULL, y IF salta su cuerpo cuando la condición es NULL. Así que el error nunca se lanzó, y cualquier usuario con sesión podía clonar tareas. La corrección es coalesce(..., false) alrededor de toda la condición.
Fue en el control de horas de una firma de auditoría, en la función detrás de “clonar tarea”.
¿Cómo era el control?
if not ( has_perm('tasks', 'create')
or v_task.created_by = auth.uid()
or v_task.assignee_id = auth.uid() ) then
raise exception 'forbidden';
end if;
Se lee como “rechazar salvo que pueda crear tareas, haya creado esta o sea suya”. Las 3683 tareas se habían sembrado automáticamente, así que created_by y assignee_id eran NULL en todas. Para un rol con todos los permisos desactivados, la condición era NOT (false OR NULL OR NULL), que es NULL, y el raise nunca se ejecutaba.
¿Por qué la misma condición es segura en RLS?
Row level security trata todo lo que no sea true como un rechazo, así que NULL significa no. Un IF de PL/pgSQL trata todo lo que no sea true como “saltar el cuerpo”, y aquí el cuerpo era el rechazo. La documentación de PostgreSQL enuncia las dos mitades, para row security y para IF; juntas convierten la misma expresión en un rechazo en un sitio y en un permiso en el otro.
Una función SECURITY DEFINER además se ejecuta por encima de RLS, así que su propio control es el único que se aplica. Basta una rama NULL: las demás pueden ser todas false.
¿Qué lo arregló?
Envuelva toda la condición para que NULL se convierta en rechazo, y trate clonar como crear una tarea, no como editar la original:
if not coalesce((select has_perm('tasks', 'create')), false) then
raise exception 'forbidden' using errcode = '42501';
end if;
¿Volvió a pasar?
Sí, unos tres meses después, en tres funciones más: archivar una tarea, cambiar su tipo y comprobar si se puede registrar tiempo en ella. Leer el SQL no las detectó. La prueba de RLS rol a rol sí, porque desactiva cada permiso y comprueba que la acción se rechaza.
¿Cómo buscarlo?
grep 'if not (' encuentra la sintaxis, no el bug. Lo que funcionó:
- Volcar el cuerpo de todas las funciones con
pg_get_functiondefen una sola consulta. - En cada
if not (...) thenyif a <> b then, quitarcoalesce(...),exists(...)y(select ...). Una cadena deordesnuda o una comparación sobre una columna que admite nulos es candidata. - Buscar también
if not <variable> theny rastrear dónde se asigna la variable._ok := has_perm(...) or created_by = me;esconde el mismo bug en dos líneas. - Llamar a cada helper usado como
if not helper(...)de tres maneras dentro de una transacción que se revierte: un id desconocido, un argumentoNULLy un usuario real al que le falta la fila esperada. Un helper que devuelveNULLen cualquiera de ellas necesita uncoalesceen el punto de llamada o dentro.
En esa aplicación la búsqueda revisó 14 helpers, todos ya seguros. El bug quedó limitado a la función de clonar y a las tres encontradas después. Un hueco relacionado en multi-tenant: lo que RLS no cubre para un usuario en dos orgs. La aplicación era el control de horas de una firma de auditoría; sus otros errores silenciosos están en control de horas en una firma de auditoría. La Auditoría de seguridad de Supabase incluye las funciones SECURITY DEFINER entre sus 46 reglas, pero no puede saber si un IF dentro de una de ellas falla en abierto, así que este barrido sigue siendo manual.