Políticas RLS Avanzadas

Las políticas básicas cubren el patrón de "cada usuario accede a sus datos". Pero en aplicaciones reales necesitas patrones más complejos: acceso por equipos, roles personalizados, relaciones entre tablas, y lógica condicional.

Políticas con JOINs

Cuando el permiso de acceso depende de datos en otra tabla, necesitas hacer un JOIN (o subquery) dentro de la política.

Acceso por equipo

El caso más común: los miembros de un equipo pueden acceder a los datos del equipo.

sql
-- Tabla de equipos
CREATE TABLE equipos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  nombre TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);
 
-- Tabla de miembros (relacion muchos-a-muchos)
CREATE TABLE miembros (
  equipo_id UUID REFERENCES equipos(id) ON DELETE CASCADE,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  rol TEXT DEFAULT 'member' CHECK (rol IN ('owner', 'admin', 'member')),
  PRIMARY KEY (equipo_id, user_id)
);
 
-- Tabla de proyectos (pertenecen a un equipo)
CREATE TABLE proyectos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  equipo_id UUID REFERENCES equipos(id) ON DELETE CASCADE NOT NULL,
  nombre TEXT NOT NULL,
  descripcion TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);
 
-- Activar RLS
ALTER TABLE equipos ENABLE ROW LEVEL SECURITY;
ALTER TABLE miembros ENABLE ROW LEVEL SECURITY;
ALTER TABLE proyectos ENABLE ROW LEVEL SECURITY;

Ahora las políticas:

sql
-- Un usuario puede ver los equipos a los que pertenece
CREATE POLICY "ver mis equipos"
ON equipos FOR SELECT
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = equipos.id
    AND miembros.user_id = auth.uid()
  )
);
 
-- Un usuario puede ver los proyectos de sus equipos
CREATE POLICY "ver proyectos de mi equipo"
ON proyectos FOR SELECT
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
  )
);

EXISTS es más eficiente que un IN con subquery porque PostgreSQL deja de buscar en cuanto encuentra la primera coincidencia.

Permisos por rol dentro del equipo

No todos los miembros tienen los mismos permisos. Los admins pueden crear proyectos, los miembros solo pueden leer:

sql
-- Solo admins y owners pueden crear proyectos
CREATE POLICY "admins crean proyectos"
ON proyectos FOR INSERT
TO authenticated
WITH CHECK (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
    AND miembros.rol IN ('owner', 'admin')
  )
);
 
-- Solo admins y owners pueden actualizar proyectos
CREATE POLICY "admins actualizan proyectos"
ON proyectos FOR UPDATE
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
    AND miembros.rol IN ('owner', 'admin')
  )
)
WITH CHECK (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
    AND miembros.rol IN ('owner', 'admin')
  )
);
 
-- Solo el owner puede eliminar proyectos
CREATE POLICY "owner elimina proyectos"
ON proyectos FOR DELETE
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
    AND miembros.rol = 'owner'
  )
);

Funciones helper para lógica compleja

Cuando la misma condición se repite en varias políticas, extrae esa lógica a una función. Esto hace tus políticas más legibles y evita duplicación.

Función: verificar membresia en equipo

sql
CREATE OR REPLACE FUNCTION es_miembro_de(p_equipo_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM miembros
    WHERE equipo_id = p_equipo_id
    AND user_id = auth.uid()
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Ahora las políticas quedan mucho más limpias:

sql
CREATE POLICY "ver proyectos de mi equipo"
ON proyectos FOR SELECT
TO authenticated
USING (es_miembro_de(equipo_id));

Función: verificar rol en equipo

sql
CREATE OR REPLACE FUNCTION tiene_rol_en(p_equipo_id UUID, p_roles TEXT[])
RETURNS BOOLEAN AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM miembros
    WHERE equipo_id = p_equipo_id
    AND user_id = auth.uid()
    AND rol = ANY(p_roles)
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Uso en políticas:

sql
-- Solo admins y owners
CREATE POLICY "admins crean proyectos"
ON proyectos FOR INSERT
TO authenticated
WITH CHECK (tiene_rol_en(equipo_id, ARRAY['owner', 'admin']));
 
-- Solo owners
CREATE POLICY "owner elimina proyectos"
ON proyectos FOR DELETE
TO authenticated
USING (tiene_rol_en(equipo_id, ARRAY['owner']));
SECURITY DEFINER vs SECURITY INVOKER

Las funciones marcadas como SECURITY DEFINER se ejecutan con los permisos del creador de la función (generalmente el superusuario), no con los permisos del usuario que la llama. Esto es necesario porque las funciones helper necesitan leer tablas que el usuario quizas no puede acceder directamente. Usalo con cuidado y solo para funciones de verificación de permisos.

Función: verificar si es admin global

Para aplicaciones con roles globales (no por equipo):

sql
CREATE OR REPLACE FUNCTION es_admin()
RETURNS BOOLEAN AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM perfiles
    WHERE id = auth.uid()
    AND rol = 'admin'
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
sql
-- Los admins pueden ver todo
CREATE POLICY "admins ven todo"
ON tareas FOR SELECT
TO authenticated
USING (es_admin() OR auth.uid() = user_id);

Políticas con datos del JWT

El JWT (JSON Web Token) de Supabase contiene metadata del usuario que puedes usar en políticas sin hacer queries adicionales.

Usar app_metadata

app_metadata es metadata que solo el servidor puede modificar (el usuario no puede cambiarla):

sql
-- Suponiendo que en app_metadata tienes { "role": "admin" }
CREATE POLICY "admin por metadata"
ON configuracion FOR ALL
TO authenticated
USING (
  (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'
);

Usar user_metadata

user_metadata es metadata que el usuario puede modificar. Util pero menos segura para permisos:

sql
-- Usar con precaucion -- el usuario puede modificar user_metadata
CREATE POLICY "filtrar por plan"
ON features_premium FOR SELECT
TO authenticated
USING (
  (auth.jwt() -> 'user_metadata' ->> 'plan') = 'premium'
);
Prefiere app_metadata para permisos

Si el permiso es critico (roles de admin, planes de pago), usa app_metadata que solo se puede modificar desde el servidor. user_metadata es mejor para preferencias no criticas.

Debugging de políticas RLS

Cuando una política no funciona como esperas, sigue estos pasos.

1. Verificar que RLS está activo

sql
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;

2. Listar todas las políticas de una tabla

sql
SELECT
  policyname,
  cmd,
  permissive,
  roles,
  qual AS using_expression,
  with_check
FROM pg_policies
WHERE tablename = 'tu_tabla';

3. Probar cómo un usuario específico

En el SQL Editor de Supabase, puedes simular ser un usuario:

sql
-- Establecer el JWT para simular un usuario
SET request.jwt.claims = '{"sub": "uuid-del-usuario", "role": "authenticated"}';
SET role = 'authenticated';
 
-- Ahora esta query se ejecuta como si fueras ese usuario
SELECT * FROM notas;
 
-- Volver al rol normal
RESET role;
SET request.jwt.claims = '';

4. Verificar auth.uid() en contexto

sql
-- Ver que retorna auth.uid() en el contexto actual
SELECT auth.uid();
 
-- Ver el JWT completo
SELECT auth.jwt();

5. Probar la condición USING manualmente

Si tu política es USING (auth.uid() = user_id), prueba la condición directamente:

sql
-- Como superusuario, ver que filas cumplirian la condicion
SELECT *, (user_id = 'uuid-del-usuario') AS cumple_politica
FROM notas;

Errores comunes al debuggear

La query devuelve filas vacias pero los datos existen:

  • La política USING no se cumple para él usuario actual
  • Olvidaste incluir el rol correcto (anon vs authenticated)
  • El usuario no está autenticado (auth.uid() retorna NULL)

Error "permission denied" en vez de filas vacias:

  • El rol no tiene GRANT en la tabla (diferente a RLS)
  • Solución: GRANT SELECT ON tabla TO authenticated;

La política funciona en el SQL Editor pero no desde el SDK:

  • El SQL Editor usa el rol postgres (superusuario) que bypasea RLS
  • Cambia el rol cómo se mostro arriba para simular un usuario real

Rendimiento de políticas RLS

Las políticas se ejecutan en cada query. Si tu política es lenta, todas las queries a esa tabla seran lentas.

Indices son fundamentales

sql
-- Si tu politica usa auth.uid() = user_id, necesitas un indice en user_id
CREATE INDEX idx_notas_user_id ON notas(user_id);
 
-- Si tu politica hace un EXISTS con miembros, necesitas indices
CREATE INDEX idx_miembros_equipo_user
ON miembros(equipo_id, user_id);

Sin estos índices, PostgreSQL hace un scan completo de la tabla en cada query. Con miles de filas, esto se nota.

Evitar subqueries costosos

sql
-- MAL: subquery que escanea toda la tabla de miembros
CREATE POLICY "ver proyectos"
ON proyectos FOR SELECT
TO authenticated
USING (
  equipo_id IN (
    SELECT equipo_id FROM miembros WHERE user_id = auth.uid()
  )
);
 
-- MEJOR: EXISTS sale en cuanto encuentra la primera coincidencia
CREATE POLICY "ver proyectos"
ON proyectos FOR SELECT
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM miembros
    WHERE miembros.equipo_id = proyectos.equipo_id
    AND miembros.user_id = auth.uid()
  )
);

Usar EXPLAIN para analizar

sql
-- Ver el plan de ejecucion de una query con RLS
SET role = 'authenticated';
SET request.jwt.claims = '{"sub": "uuid-del-usuario", "role": "authenticated"}';
 
EXPLAIN ANALYZE
SELECT * FROM proyectos;
 
RESET role;

Busca en la salida:

  • Seq Scan -- scan secuencial, puede ser lento en tablas grandes
  • Index Scan -- usa un índice, generalmente rápido
  • Nested Loop -- si el número de loops es alto, puede ser lento
Regla general de rendimiento

Si tu tabla tiene más de 10,000 filas y tu política hace JOINs o subqueries, asegurate de tener índices en todas las columnas involucradas en las condiciones. La diferencia puede ser de milisegundos vs segundos.

Considerar materialized views para queries complejos

Si la lógica de permisos es muy compleja y afecta el rendimiento, puedes crear una vista materializada (materialized view):

sql
-- Vista que pre-calcula los equipos de cada usuario
CREATE MATERIALIZED VIEW user_equipos AS
SELECT user_id, equipo_id, rol
FROM miembros;
 
-- Indice en la vista
CREATE INDEX idx_user_equipos ON user_equipos(user_id, equipo_id);
 
-- Refrescar la vista cuando cambian los miembros
-- (hazlo con un trigger o manualmente)
REFRESH MATERIALIZED VIEW user_equipos;

Esto solo tiene sentido si la tabla de miembros cambia poco y las queries son frecuentes.

Patron: acceso compartido (invitaciones)

Para funcionalidades como "compartir un documento con alguien":

sql
-- Tabla de documentos
CREATE TABLE documentos (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  owner_id UUID DEFAULT auth.uid() REFERENCES auth.users(id) NOT NULL,
  titulo TEXT NOT NULL,
  contenido TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);
 
-- Tabla de accesos compartidos
CREATE TABLE accesos (
  documento_id UUID REFERENCES documentos(id) ON DELETE CASCADE,
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  permiso TEXT DEFAULT 'read' CHECK (permiso IN ('read', 'write')),
  PRIMARY KEY (documento_id, user_id)
);
 
ALTER TABLE documentos ENABLE ROW LEVEL SECURITY;
ALTER TABLE accesos ENABLE ROW LEVEL SECURITY;
 
-- El dueno ve sus documentos
CREATE POLICY "dueno ve documentos"
ON documentos FOR SELECT
TO authenticated
USING (auth.uid() = owner_id);
 
-- Usuarios con acceso compartido ven documentos
CREATE POLICY "acceso compartido ve documentos"
ON documentos FOR SELECT
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM accesos
    WHERE accesos.documento_id = documentos.id
    AND accesos.user_id = auth.uid()
  )
);
 
-- Solo el dueno puede editar
CREATE POLICY "dueno edita documentos"
ON documentos FOR UPDATE
TO authenticated
USING (auth.uid() = owner_id)
WITH CHECK (auth.uid() = owner_id);
 
-- Usuarios con permiso 'write' también pueden editar
CREATE POLICY "acceso write edita documentos"
ON documentos FOR UPDATE
TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM accesos
    WHERE accesos.documento_id = documentos.id
    AND accesos.user_id = auth.uid()
    AND accesos.permiso = 'write'
  )
)
WITH CHECK (
  -- No permite cambiar el owner_id
  EXISTS (
    SELECT 1 FROM accesos
    WHERE accesos.documento_id = documentos.id
    AND accesos.user_id = auth.uid()
    AND accesos.permiso = 'write'
  )
);

Indices necesarios:

sql
CREATE INDEX idx_accesos_documento_user ON accesos(documento_id, user_id);
CREATE INDEX idx_accesos_user ON accesos(user_id);

Patron: soft delete con RLS

En vez de eliminar filas, marcalas como eliminadas y usalas en la política:

sql
-- Agregar columna de soft delete
ALTER TABLE notas ADD COLUMN deleted_at TIMESTAMPTZ DEFAULT NULL;
 
-- Solo mostrar filas no eliminadas
CREATE POLICY "ver notas activas"
ON notas FOR SELECT
TO authenticated
USING (
  auth.uid() = user_id
  AND deleted_at IS NULL
);
 
-- "Eliminar" es en realidad un update
CREATE POLICY "soft delete mis notas"
ON notas FOR UPDATE
TO authenticated
USING (auth.uid() = user_id AND deleted_at IS NULL)
WITH CHECK (auth.uid() = user_id);

Desde TypeScript:

typescript
// Soft delete -- en vez de .delete()
const { error } = await supabase
  .from('notas')
  .update({ deleted_at: new Date().toISOString() })
  .eq('id', notaId)

Resumen

  • Usa EXISTS con subqueries para políticas basadas en relaciones entre tablas
  • Extrae lógica repetida a funciones helper con SECURITY DEFINER
  • Los datos del JWT (auth.jwt()) permiten políticas sin queries adicionales
  • Prefiere app_metadata sobre user_metadata para permisos criticos
  • Siempre crea índices en las columnas que tus políticas usan en condiciones
  • Usa EXPLAIN ANALYZE para diagnosticar políticas lentas
  • Para debuggear, simula usuarios con SET role y SET request.jwt.claims