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 tenant | Polí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ón | Patrón | Quién autoriza |
|---|---|---|
| Actúas por cuenta de un usuario logueado | Cliente de sesión (anon + JWT) | RLS |
| Dato cerrado con lógica de permiso | RPC SECURITY DEFINER + SET search_path | La función, con auth.uid() |
| Hace falta exceder los derechos del usuario | Service-role después de autorizar | El código, con la identidad real |
| Webhook, cola o cron: no hay usuario | Service-role | Guard 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 enIF to_regclass('public.x') IS NOT NULLy usaDROP POLICY IF EXISTS. - Timestamp libre.
devse mueve rápido; comprueba colisiones justo antes de empujar. Una colisión rompesupabase db pushconschema_migrations_pkey, y renombrar uno de los dos después puede dejarlo sin aplicar en otro entorno (veroperaciones/despliegue.md§7). - DEV primero. Push a
devdisparapipeline.yml; amain,deploy-prod.yml, que hacedb dumpantes de tocar nada.
Nunca
- Secretos, tokens o contraseñas de prueba versionados.
- PHI en
logger.debugsin redactar. - Tokens en query strings: quedan en logs de acceso y en proxies.