Saltar al contenido principal

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​

ClienteEfecto
createServerSupabase(), cliente de navegadorRol authenticated (o anon): sujeto a grants y RLS
createAdminSupabase(), SUPABASE_CLIENT del backendService-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 code y lo canjea el backend con el JWT de la sesión y el tenant_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:

  1. REVOKE de escritura. El SELECT sigue abierto, así que los EXISTS inline de las demás políticas siguen funcionando y no hace falta escribir ninguna.
  2. 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íntomaCausaArreglo
Un DELETE "denegado" con 23503Violación de FK, no de permiso: el permiso sí estabaApuntar a una tabla hoja, o WHERE id IS NULL
Una lectura devuelve 0 filas "correctamente"La prueba no fijó test.uid: auth.uid() es NULLFijar el uid, como hace el login
Una RPC "no aplicó el cambio"Se lee el resultado dentro del rol, y RLS lo ocultaComprobar fuera del rol
Una prueba de aislamiento pasa solaEl usuario "forastero" resultó ser superadminUn usuario que no sea ni miembro ni superadmin
Una prueba destructiva contamina a las siguientesUn TRUNCATE se llevó la semillaRe-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​

  1. 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;
  1. 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');
  1. 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.