Saltar al contenido principal

Estándares de seguridad

Xenpia es multi-tenant y guarda PHI: pacientes, recetas, citas, historias clínicas. Una fuga entre tenants no es un fallo de interfaz, es un incidente de datos clínicos. Estos estándares no son preferencias de estilo.

Salen de cerrar los 17 objetos que el Security Advisor de Supabase marcó en septiembre de 2026. La causa raíz fue una sola: el núcleo de la aplicación —memberships, tenants, bots, las tablas de canales— se creó a mano fuera de supabase/migrations/, sin RLS ni GRANT. Todo lo que sigue existe para que eso no vuelva a pasar.

Regla 0 — nada de SQL a mano​

Toda tabla, vista o función nace en supabase/migrations/. Nunca en el SQL Editor del dashboard, nunca en frontend/bd/, nunca en backend/src/database/schema.sql.

Lo que se crea fuera de migraciones no lo revisa nadie y no llega a los dos entornos. Las 17 alertas del Advisor dibujaban exactamente esa frontera: lo anterior a la disciplina de migraciones estaba abierto, lo posterior no.

Regla 1 — el acceso se decide al crear la tabla​

Por defecto, Supabase concede ALL a anon y authenticated sobre el esquema public. Si no revocas, la anon key —que viaja en el bundle del navegador— puede leer y escribir tu tabla. Así es como memberships quedó con INSERT, UPDATE, DELETE y TRUNCATE abiertos a cualquiera: bastaba insertarse una fila con rol admin para que todas las demás políticas del sistema devolvieran true.

ALTER TABLE public.x ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.x FROM anon, authenticated; -- se parte de cero
-- y después se concede SOLO lo que algún flujo real necesita

Tres formas habituales, según quién use la tabla:

La tabla la usa…Tratamiento
Solo el backend (service-role)Cierre total: ENABLE RLS + REVOKE ALL
Catálogo global (sin tenant_id)GRANT SELECT TO authenticated + política FOR SELECT USING (true)
El panel, con CRUD por tenantPolítica FOR ALL acotada por pertenencia, con USING y WITH CHECK

El WITH CHECK no es decorativo: sin él, un UPDATE puede reasignar una fila a otro tenant, porque USING solo mira la fila de origen.

Regla 2 — service-role no es el fallo​

Son dos ejes independientes: con qué privilegios hablas con la base, y quién te lo está pidiendo. El problema nunca fue usar service-role, sino usarlo sin haber establecido lo segundo.

SituaciónPatrónQuién autoriza
Actúas por cuenta de un usuario logueadoCliente de sesión (anon + JWT)RLS
Dato cerrado con lógica de permisoRPC SECURITY DEFINER + SET search_pathLa función, con auth.uid()
Hace falta exceder los derechos del usuarioService-role después de autorizarEl código, con la identidad real
Webhook, cola o cron: no hay usuarioService-roleGuard del endpoint + filtro de tenant

El backend NestJS no puede dejar service-role, y no por comodidad: cuando entra un webhook de Meta o se procesa un job de BullMQ no hay usuario, luego no hay auth.uid() ni RLS que aplicar. Ya se intentó con la clave publicable y tumbó todos los canales con 42501.

De ahí un corolario que sorprende: user_has_permission() no se puede llamar desde el backend. Resuelve al usuario con auth.uid(), que bajo service-role es NULL, así que devolvería siempre false. La autorización del backend va en guards de NestJS, en TypeScript.

Regla 3 — una server action es un endpoint HTTP público​

createServerSupabase() no exige sesión: sin cookies es rol anon. Y una server action de Next se invoca con un POST y la cabecera Next-Action desde cualquier sitio.

Si la función acaba usando createAdminSupabase(), empieza por la sesión:

const { data: sesion } = await supabase.auth.getUser();
if (!sesion?.user) throw new Error('No autorizado: se requiere sesión');

Sin esto, updateUserInTenant cambiaba la contraseña de cualquier usuario sin estar autenticado.

Y lo que llega por argumento —userId, tenantId, isSuperAdmin— no es autorización: es entrada controlada por quien llama. La condición se resuelve en el servidor con user_is_superadmin() o user_has_permission().

Regla 4 — permisos que existen​

No inventes un código de permiso nuevo con un backfill adivinado por nombre de rol. Busca el que ya usa el catálogo: dashboard.user.view, core.modules.manage, health.records.manage, scheduling.*.

Si de verdad hace falta uno nuevo, créalo y haz el backfill explícito — el trigger de alta solo reparte permisos en el momento del registro, así que un permiso añadido después no llega solo. El patrón está en 20260813_module_platform.sql:261-278.

Secretos​

Una columna con un token o una contraseña se trata aparte del resto de la tabla. Si algún flujo de sesión necesita leer otras columnas, usa GRANT por columna: se puede escribir un valor que no se puede leer de vuelta.

Con una excepción importante: ON CONFLICT DO UPDATE exige SELECT sobre las columnas del SET. Si necesitas un upsert sobre una columna secreta, no hay GRANT que valga — va por RPC SECURITY DEFINER.

Y si un secreto se filtra, se rota. Borrarlo del árbol no lo quita del historial de git.

Vistas​

Una vista se ejecuta con los permisos de su dueño, así que se salta el RLS de las tablas de debajo. Es lo que reporta el lint Security Definer View.

Dos salidas: security_invoker = on si quien consulta puede leer lo de abajo, o cerrar la vista y envolverla en una función SECURITY DEFINER que devuelva SETOF la vista — esto último funciona sin conocer su definición, que es justo lo que hacía falta con tenant_users_v.

Probarlo​

tsc, vitest y eslint no ven SQL. Un GRANT solo se prueba de una forma: siendo el rol.

cd supabase/tests
./run.sh # sin migraciones → ROJO
./run.sh ../migrations/2026XXXX_*.sql # con la migración → VERDE

Levanta un Postgres desechable en Docker con roles anon, authenticated y service_role, y auth.uid() alimentado desde un GUC. Rojo antes que verde. Y por cada cosa que cierras, escribe la prueba de regresión de lo que NO debe romperse: que el panel siga viendo lo suyo, que service_role siga leyendo. Esas son las que avisan cuando algo va mal.

Migraciones​

  • Idempotentes y con guardas. En DEV el esquema puede ir por detrás del sello de schema_migrations, así que envuelve en IF to_regclass('public.x') IS NOT NULL y usa DROP POLICY IF EXISTS.
  • Timestamp libre. dev se mueve rápido; comprueba colisiones justo antes de empujar. Una colisión rompe supabase db push con schema_migrations_pkey, y renombrar uno de los dos después puede dejarlo sin aplicar en otro entorno (ver operaciones/despliegue.md §7).
  • DEV primero. Push a dev dispara pipeline.yml; a main, deploy-prod.yml, que hace db dump antes de tocar nada.

Nunca​

  • Secretos, tokens o contraseñas de prueba versionados.
  • PHI en logger.debug sin redactar.
  • Tokens en query strings: quedan en logs de acceso y en proxies.