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.
-- 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:
-- 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:
-- 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
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:
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
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:
-- 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):
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;-- 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):
-- 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:
-- 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
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;2. Listar todas las políticas de una tabla
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:
-- 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
-- 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:
-- 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 (
anonvsauthenticated) - 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
-- 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
-- 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
-- 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):
-- 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":
-- 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:
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:
-- 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:
// Soft delete -- en vez de .delete()
const { error } = await supabase
.from('notas')
.update({ deleted_at: new Date().toISOString() })
.eq('id', notaId)Resumen
- Usa
EXISTScon 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_metadatasobreuser_metadatapara permisos criticos - Siempre crea índices en las columnas que tus políticas usan en condiciones
- Usa
EXPLAIN ANALYZEpara diagnosticar políticas lentas - Para debuggear, simula usuarios con
SET roleySET request.jwt.claims