Auditar lo que ya existe
Cerrar un agujero en código nuevo es fácil: decides el acceso al escribirlo. Cerrarlo en código que ya está en producción es otra cosa, porque hay flujos vivos que dependen del estado actual —a veces precisamente del agujero.
Este método salió de cerrar 17 objetos del Security Advisor sin tumbar el panel. No es opcional ni "si da tiempo": es lo que distingue cerrar el aviso de romper la aplicación.
El método de impacto, en 6 pasos
1. Inventariar TODA referencia
No solo .from('x') con escritura:
grep -rn "<tabla>" --include=*.ts --include=*.tsx frontend/src backend/src
Buscar solo escrituras en una línea deja fuera lo que está partido en varias.
El .update() que abría la toma de control de cuentas estaba en la línea
siguiente al .from('memberships').
2. Clasificar por cliente
| Cliente | Efecto |
|---|---|
createServerSupabase(), cliente de navegador | Rol authenticated (o anon): sujeto a grants y RLS |
createAdminSupabase(), SUPABASE_CLIENT del backend | Service-role: inmune, salta todo |
Si todos los consumidores son service-role, puedes cerrar la tabla entera y no se rompe nada. Si hay consumidores de sesión, necesitas políticas.
3. Clasificar por operación
SELECT sobrevive a un REVOKE de escritura; INSERT/UPDATE/DELETE no.
RLS, en cambio, afecta a las cuatro. Esta distinción es la que permite cerrar
memberships en dos fases sin dejar a nadie fuera.
4. Rastrear las funciones SQL
SECURITY DEFINER ignora grants y RLS; SECURITY INVOKER no. Una tabla cerrada
sigue siendo escribible desde una RPC SECURITY DEFINER, y eso suele ser la
solución, no el problema.
Ojo también con los triggers: el de alta de usuario es SECURITY DEFINER,
así que revocar permisos no rompe el registro.
Y vuelca las políticas que ya existen sobre la tabla antes de activar RLS: si estaban dormidas, despiertan en ese momento (ver la trampa más abajo).
5. Barrer los flujos transversales
Los que no aparecen buscando el nombre de la tabla, pero dependen de ella:
- Impersonación — es una sesión real del usuario destino, así que
auth.uid()es él. - Login — resuelve el tenant leyendo
memberships. - Alta por email — redirige a verificar el correo: todavía no hay sesión. Éste rompió el diseño previsto para los consentimientos.
- Onboarding de canales de Meta — Embedded Signup no vuelve por redirect: la
página recibe el
codey lo canjea el backend con el JWT de la sesión y eltenant_id(PermissionGuard). Guardar las credenciales comprueba propiedad con la sesión antes de escribir con el cliente admin. - Invitaciones, alta de tenant,
data-sync, seeders, y el webhook de cada canal.
6. Nombrar la víctima
Di en voz alta qué se rompe. Si el resultado es "no rompe nada", desconfía y repite el paso 5. En este lote, cada vez que algo parecía indoloro había un flujo transversal sin mirar.
Las trampas, y lo que costó cada una
El 200 con 0 filas
Un UPDATE o DELETE bloqueado por RLS en PostgREST devuelve 200, 0 filas y
error: null. Todo código que asuma "si RLS bloquea, me llega un error" falla
en silencio.
Es el fallo original de updateUserInTenant: sus dos guardas no disparaban
nunca, y se llegaba a updateUserById({ password }) con service-role.
Consecuencia práctica: al cerrar una tabla, REVOKE antes que RLS sin
política. Un REVOKE devuelve 42501, que es ruidoso y detectable; RLS sin
política deja el fallo mudo.
ON CONFLICT DO UPDATE exige SELECT sobre el SET
El plan decía que bastaba un GRANT por columna para ocultar los tokens de Meta
sin tocar código. Falso: saveTelegramCredentials hace un upsert, y
ON CONFLICT DO UPDATE SET access_token = ... exige SELECT sobre
access_token. Permitirlo desde la sesión obligaba a hacer el token legible,
justo lo contrario del objetivo.
Lo importante del método: la primera prueba usaba un INSERT simple y daba
verde. Solo saltó al ejercitar el ON CONFLICT sobre una fila existente, que
es lo que hace el código real.
memberships no se cierra de golpe
Es la tabla que consulta toda política del sistema, directa o vía
user_has_permission(). Se hizo en dos fases:
REVOKEde escritura. ElSELECTsigue abierto, así que losEXISTSinline de las demás políticas siguen funcionando y no hace falta escribir ninguna.- RLS de lectura con
user_id = auth.uid(), comprobando en la misma tanda de pruebas que las políticas que dependen de ella siguen funcionando.
Si se hubiera activado RLS en la fase 1, cada política del núcleo de canales habría necesitado además su propia política de lectura.
La solución puede estar ya escrita
Antes de escribir una RPC nueva, mira si alguien la escribió ya. En este lote
se creó tenant_member_set_role y hubo que descartarla: dev ya tenía
update_membership_role, con la misma lógica pero apoyada en un permiso que
sí existe en el catálogo.
Las políticas dormidas se despiertan
Varias tablas del núcleo tenían políticas escritas y RLS apagado — el lint Policy Exists RLS Disabled. Mientras RLS está apagado esas políticas no hacen nada, así que nadie las revisa. En cuanto lo activas, se aplican todas.
Y las políticas del mismo comando se combinan con OR: una sola con
USING (true) anula el aislamiento por tenant que acabas de escribir. En bots
despertó tenant_read_bots, que por suerte hacía la misma comprobación que la
nueva. Pudo no ser así.
Antes de activar RLS, vuélcalas en cada entorno — creadas a mano, no tienen por qué coincidir entre DEV y PROD:
select p.polname, p.polcmd,
(select array_agg(rolname) from pg_roles where oid = any(p.polroles)) as roles,
pg_get_expr(p.polqual, p.polrelid) as using_expr
from pg_policy p where p.polrelid = 'public.<tabla>'::regclass;
roles vacío significa TO PUBLIC, anon incluido. Si una tabla se cierra con
REVOKE ALL, las políticas dan igual: sin privilegio de tabla no llegan a
evaluarse. Por eso, cuando no conoces su texto, cerrar es más seguro que activar.
Los scripts no se typechequean
backend/tsconfig.json solo incluye src. Lo de backend/scripts/ no entra
en el build ni en el CI, así que borrar o cambiar un export que un script
importa lo rompe sin que nadie se entere. Pasó al borrar los scripts de
alta de un cliente: uno importaba del otro. Existe npm run typecheck:scripts;
córrelo si tocas algo que un script usa.
Variables que consume un servicio externo
Al auditar el .env, un grep de process.env da por muertas variables que
están vivas: las credenciales de OAuth de Google, GitHub y Twitter las consume
Supabase Auth desde su dashboard, no el código. Antes de borrar una variable
que huela a credencial de un tercero, averigua dónde se configura.
Falsos verdes en las pruebas
Una prueba que pasa por el motivo equivocado es peor que una que falla.
| Síntoma | Causa | Arreglo |
|---|---|---|
Un DELETE "denegado" con 23503 | Violación de FK, no de permiso: el permiso sí estaba | Apuntar a una tabla hoja, o WHERE id IS NULL |
| Una lectura devuelve 0 filas "correctamente" | La prueba no fijó test.uid: auth.uid() es NULL | Fijar el uid, como hace el login |
| Una RPC "no aplicó el cambio" | Se lee el resultado dentro del rol, y RLS lo oculta | Comprobar fuera del rol |
| Una prueba de aislamiento pasa sola | El usuario "forastero" resultó ser superadmin | Un usuario que no sea ni miembro ni superadmin |
| Una prueba destructiva contamina a las siguientes | Un TRUNCATE se llevó la semilla | Re-sembrar antes de cada caso |
Al terminar
Antes de promover a PROD, el humo mínimo en DEV:
Login · cambio de tenant · impersonación · ficha y cambio de rol de usuario · crear tenant con admin · alta de bot de Telegram · alta de WhatsApp Embedded Signup de punta a punta.
Y la prueba negativa, que es la que demuestra el arreglo: con la anon key, lo que cerraste debe devolver 0 filas o error de permiso.
Si llega un aviso del Security Advisor
- No te fíes del correo: trae una muestra. El cuadro completo sale de la base:
select c.relname, c.relrowsecurity as rls_activo, count(p.polname) as politicas
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by 1, 2
order by c.relrowsecurity, c.relname;
- Comprueba también las concesiones, que RLS no cuenta toda la historia:
select grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public' and table_name = '<tabla>'
and grantee in ('anon', 'authenticated');
- Ordena por riesgo de romper, no por severidad del aviso: empieza por lo que no tiene consumidores de sesión, que valida el camino a producción sin arriesgar ninguna pantalla.
Detalle operativo y comandos: skill seguridad.