Control de acceso avanzado basado en roles en PostgreSQL: seguridad a nivel de fila y gestión de políticas

Utiliza la Seguridad a Nivel de Fila (RLS) de PostgreSQL con políticas RBAC para aislamiento de inquilinos, verificaciones de propiedad y un acceso a la aplicación más seguro.

Control de Acceso Avanzado Basado en Roles en PostgreSQL: Seguridad a Nivel de Fila y Gestión de Políticas

La Seguridad a Nivel de Fila (RLS) de PostgreSQL permite que tu base de datos filtre filas según el rol actual y el contexto de la sesión. Si tu aplicación atiende a múltiples inquilinos o combina acceso de administrador, gerente y usuario regular, RLS puede evitar que un simple WHERE tenant_id = ... olvidado se convierta en una fuga de datos.

RLS no reemplaza la autorización de la aplicación. Te proporciona una red de seguridad a nivel de base de datos para el límite más sensible: qué filas puede leer o escribir una conexión.

¿Por qué Seguridad a Nivel de Fila?

Problemas del Enfoque Tradicional

Filtrado a nivel de aplicación:

# Cada consulta debe recordar filtrar
query = "SELECT * FROM documents WHERE tenant_id = %s"
results = db.execute(query, [current_user_tenant_id])

Problemas:

  • Fácil olvidar filtros (vulnerabilidades de seguridad)
  • Duplicación de código en toda la aplicación
  • Sin protección para acceso directo a la base de datos
  • Requisitos de auditoría complejos

Beneficios de la Seguridad a Nivel de Fila

  • Aplicación automática: Las políticas se aplican de forma transparente
  • Reglas centralizadas: La lógica de seguridad en un solo lugar
  • Aislamiento multiinquilino: Perfecto para aplicaciones SaaS
  • Cumplimiento de auditoría: Garantías de seguridad integradas
  • Rendimiento: Planificación eficiente de consultas

Configuración Básica de RLS

Paso 1: Habilitar RLS en la Tabla

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200),
    content TEXT,
    owner_id INTEGER NOT NULL,
    department VARCHAR(50),
    is_public BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Habilitar seguridad a nivel de fila
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

Paso 2: Crear Roles

-- Crear diferentes roles de usuario
CREATE ROLE regular_user;
CREATE ROLE department_manager;
CREATE ROLE admin;

-- Otorgar acceso a la tabla
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO regular_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO department_manager;
GRANT ALL ON documents TO admin;

Paso 3: Crear Políticas

-- Política: Los usuarios pueden ver sus propios documentos
CREATE POLICY user_own_documents ON documents
    FOR SELECT
    TO regular_user
    USING (owner_id = current_setting('app.user_id')::INTEGER);

-- Política: Los usuarios pueden insertar documentos de su propiedad
CREATE POLICY user_insert_own ON documents
    FOR INSERT
    TO regular_user
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

-- Política: Los usuarios pueden actualizar sus propios documentos
CREATE POLICY user_update_own ON documents
    FOR UPDATE
    TO regular_user
    USING (owner_id = current_setting('app.user_id')::INTEGER)
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

Paso 4: Establecer Variables de Sesión

En código de aplicación:

import psycopg2

# Conectar como rol
conn = psycopg2.connect(
    host="localhost",
    database="mydb",
    user="regular_user",
    password="password"
)

# Establecer contexto de usuario
cursor = conn.cursor()
cursor.execute("SELECT set_config('app.user_id', %s, false)", [str(current_user_id)])

# Ahora las consultas se filtran automáticamente
cursor.execute("SELECT * FROM documents")
# Solo devuelve documentos donde owner_id = current_user_id

Aplicación SaaS Multiinquilino

Configuración Completa Multiinquilino

-- Crear tabla de inquilinos
CREATE TABLE tenants (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Crear tabla de usuarios
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    tenant_id INTEGER REFERENCES tenants(id),
    role VARCHAR(20) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Crear tabla de datos de aplicación
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL REFERENCES tenants(id),
    customer_name VARCHAR(100),
    amount DECIMAL(10,2),
    status VARCHAR(20),
    created_by INTEGER REFERENCES users(id),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Habilitar RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Política: Los usuarios solo ven pedidos de su inquilino
CREATE POLICY tenant_isolation ON orders
    FOR ALL
    TO public
    USING (tenant_id = current_setting('app.tenant_id')::INTEGER)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::INTEGER);

Integración con la Aplicación

Ejemplo en Django:

from django.db import connection

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.user.is_authenticated:
            with connection.cursor() as cursor:
                cursor.execute(
                    "SELECT set_config('app.tenant_id', %s, false)",
                    [str(request.user.tenant_id)]
                )
        
        response = self.get_response(request)
        return response

Ejemplo en Node.js:

const { Pool } = require('pg');
const pool = new Pool();

async function setTenantContext(tenantId) {
    const client = await pool.connect();
    try {
        await client.query('SELECT set_config($1, $2, false)', ['app.tenant_id', String(tenantId)]);
        return client;
    } catch (err) {
        client.release();
        throw err;
    }
}

// Uso
app.use(async (req, res, next) => {
    if (req.user) {
        req.dbClient = await setTenantContext(req.user.tenantId);
    }
    next();
});

Patrones Avanzados de Políticas

1. Control de Acceso Jerárquico

-- Los usuarios pueden ver documentos de su departamento y los públicos
CREATE POLICY department_access ON documents
    FOR SELECT
    TO regular_user
    USING (
        department = current_setting('app.user_department', true)
        OR is_public = true
        OR owner_id = current_setting('app.user_id')::INTEGER
    );

2. Acceso Basado en Tiempo

CREATE TABLE subscriptions (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    content TEXT,
    valid_from TIMESTAMP NOT NULL,
    valid_until TIMESTAMP NOT NULL
);

ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

-- Política: Los usuarios solo pueden ver suscripciones activas
CREATE POLICY active_subscriptions ON subscriptions
    FOR SELECT
    TO public
    USING (
        user_id = current_setting('app.user_id')::INTEGER
        AND NOW() BETWEEN valid_from AND valid_until
    );

3. Acceso Basado en Roles con Lógica Compleja

CREATE TABLE projects (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    owner_id INTEGER,
    team_id INTEGER,
    visibility VARCHAR(20) -- 'private', 'team', 'public'
);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Política de visibilidad compleja
CREATE POLICY project_visibility ON projects
    FOR SELECT
    TO public
    USING (
        CASE current_setting('app.user_role', true)
            WHEN 'admin' THEN true
            WHEN 'manager' THEN (
                team_id = current_setting('app.user_team_id')::INTEGER
                OR visibility = 'public'
            )
            ELSE (
                owner_id = current_setting('app.user_id')::INTEGER
                OR visibility = 'public'
            )
        END
    );

4. Listas de Acceso Compartido

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200),
    owner_id INTEGER NOT NULL
);

CREATE TABLE document_shares (
    document_id INTEGER REFERENCES documents(id),
    shared_with_user_id INTEGER,
    permission VARCHAR(20), -- 'read', 'write'
    PRIMARY KEY (document_id, shared_with_user_id)
);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Política: Los usuarios pueden ver documentos propios o compartidos
CREATE POLICY document_access ON documents
    FOR SELECT
    TO public
    USING (
        owner_id = current_setting('app.user_id')::INTEGER
        OR id IN (
            SELECT document_id 
            FROM document_shares 
            WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
        )
    );

-- Política: Los usuarios pueden modificar documentos propios o compartidos con permiso de escritura
CREATE POLICY document_modify ON documents
    FOR UPDATE
    TO public
    USING (
        owner_id = current_setting('app.user_id')::INTEGER
        OR id IN (
            SELECT document_id 
            FROM document_shares 
            WHERE shared_with_user_id = current_setting('app.user_id')::INTEGER
              AND permission = 'write'
        )
    );

Acceso Administrativo y RLS

Los propietarios de tablas y superusuarios pueden omitir RLS a menos que se fuerce. Trata las rutas administrativas como una decisión de diseño separada, no como una ocurrencia tardía.

Opción 1: Atributo de Rol BYPASSRLS

CREATE ROLE admin_user WITH LOGIN PASSWORD 'secure_password';
ALTER ROLE admin_user BYPASSRLS;

Úsalo con moderación. BYPASSRLS es poderoso y no debe otorgarse a roles normales de aplicación.

Opción 2: Políticas Permisivas para Administradores

-- Crear política permisiva para el rol admin
CREATE POLICY admin_all_access ON documents
    FOR ALL
    TO admin
    USING (true)
    WITH CHECK (true);

Opción 3: Forzar RLS para Propietarios de Tablas

ALTER TABLE documents FORCE ROW LEVEL SECURITY;

FORCE ROW LEVEL SECURITY hace que los propietarios de tablas también sigan las políticas. Esto suele ser más seguro para tablas propiedad de la aplicación. Para trabajos de mantenimiento, usa un rol controlado separado en lugar de deshabilitar RLS dentro de un script ad hoc.

Tipos de Políticas: USING vs WITH CHECK

Cláusula USING

Controla qué filas existentes son visibles/modificables:

CREATE POLICY select_own ON documents
    FOR SELECT
    USING (owner_id = current_setting('app.user_id')::INTEGER);

-- El usuario solo puede SELECT filas donde owner_id coincida

Cláusula WITH CHECK

Controla qué filas nuevas/actualizadas están permitidas:

CREATE POLICY insert_own ON documents
    FOR INSERT
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

-- El usuario solo puede INSERT filas donde owner_id coincida

Políticas Combinadas

CREATE POLICY update_own ON documents
    FOR UPDATE
    USING (owner_id = current_setting('app.user_id')::INTEGER)
    WITH CHECK (owner_id = current_setting('app.user_id')::INTEGER);

Consideraciones de Rendimiento

1. Indexar Columnas de Políticas

-- Crear índices para columnas usadas en políticas
CREATE INDEX idx_documents_owner ON documents(owner_id);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
CREATE INDEX idx_documents_department ON documents(department);

2. Analizar Planes de Consulta

EXPLAIN ANALYZE
SELECT * FROM documents
WHERE title LIKE '%report%';

-- Verificar si el filtro de política se aplica eficientemente

3. Cuidado con Subconsultas en Políticas

Las subconsultas a veces son el modelo correcto para acceso compartido, pero necesitan índices y pruebas. Una política que consulta otra tabla por cada fila candidata puede volverse costosa.

Riesgoso sin índices:

CREATE POLICY slow_policy ON documents
    USING (
        id IN (
            SELECT document_id
            FROM shares
            WHERE user_id = current_setting('app.user_id')::INTEGER
        )
    );

A menudo más claro con índices de soporte:

CREATE POLICY faster_policy ON documents
    USING (
        EXISTS (
            SELECT 1 FROM shares 
            WHERE document_id = documents.id 
              AND user_id = current_setting('app.user_id')::INTEGER
        )
    );

Crea índices como shares(user_id, document_id) y verifica el plan con EXPLAIN (ANALYZE, BUFFERS).

Monitoreo y Auditoría

Ver Políticas Activas

-- Listar todas las políticas RLS
SELECT 
    schemaname,
    tablename,
    policyname,
    permissive,
    roles,
    cmd,
    qual,
    with_check
FROM pg_policies
ORDER BY tablename, policyname;

Auditar Cambios en Políticas

Rastrea DDL de políticas en migraciones y registros de auditoría de la base de datos. Si tu entorno usa disparadores de eventos, pruébalos cuidadosamente contra tu versión de PostgreSQL e incluye eventos CREATE POLICY, ALTER POLICY y DROP POLICY. No confíes en un fragmento de disparador no probado como evidencia de cumplimiento.

Probar Efectividad de Políticas

-- Crear función de prueba
CREATE OR REPLACE FUNCTION test_user_access(
    test_user_id INTEGER,
    test_tenant_id INTEGER
)
RETURNS TABLE(document_id INTEGER, title VARCHAR) AS $$
BEGIN
    -- Establecer variables de sesión
    PERFORM set_config('app.user_id', test_user_id::text, false);
    PERFORM set_config('app.tenant_id', test_tenant_id::text, false);
    
    -- Devolver documentos accesibles
    RETURN QUERY SELECT id, documents.title FROM documents;
END;
$$ LANGUAGE plpgsql SECURITY INVOKER;

-- Probar acceso
SELECT * FROM test_user_access(123, 5);

Solución de Problemas

Problema 1: Políticas No Aplicadas

Verificar si RLS está habilitado:

SELECT tablename, rowsecurity 
FROM pg_tables 
WHERE schemaname = 'public' AND tablename = 'documents';

Habilitar si está deshabilitado:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

Problema 2: Variables de Sesión No Establecidas

Depurar configuraciones actuales:

SHOW app.user_id;
SHOW app.tenant_id;

-- O usar current_setting
SELECT current_setting('app.user_id', true);

Problema 3: Degradación del Rendimiento

Identificar políticas lentas:

EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM documents WHERE title = 'test';

-- Buscar operaciones "Filter" con condiciones de política

Solución: Simplificar políticas o agregar índices

Problema 4: Acceso Denegado Inesperado

Probar como usuario específico:

SET ROLE regular_user;
SELECT set_config('app.user_id', '123', false);

SELECT * FROM documents;
-- Muestra lo que ese usuario puede ver realmente

RESET ROLE;

Mejores Prácticas

  1. Siempre establecer variables de sesión: Asegúrate de que los valores de current_setting() estén poblados
  2. Indexar columnas de políticas: Crea índices en todas las columnas usadas en USING/WITH CHECK
  3. Probar exhaustivamente: Verifica políticas con diferentes roles y escenarios
  4. Mantener políticas simples: La lógica compleja impacta el rendimiento
  5. Usar EXPLAIN: Analiza planes de consulta con RLS habilitado
  6. Documentar políticas: Comenta la lógica de políticas para mantenibilidad
  7. Separar preocupaciones: Usa diferentes políticas para SELECT, INSERT, UPDATE, DELETE
  8. Monitorear rendimiento: Configura alertas para consultas lentas

Conclusión

Comienza con una política simple: aislamiento de inquilinos en tus tablas de mayor riesgo. Establece el contexto de sesión con set_config, indexa cada columna usada por las políticas, y prueba lecturas y escrituras como roles reales de aplicación.

Una vez que el límite básico funcione, agrega políticas de propiedad, gerente o acceso compartido solo donde el producto las necesite. Las políticas pequeñas y probadas son más fáciles de razonar que una regla ingeniosa que intenta codificar todo tu modelo de permisos.