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

Domine la seguridad a nivel de fila (RLS) en PostgreSQL para un control de acceso de grano fino, aislamiento multiinquilino y autorización basada en políticas. Guía completa con patrones de implementación del mundo real.

28 vistas

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

Introducción

La Seguridad a Nivel de Fila (RLS) de PostgreSQL proporciona un control de acceso detallado a nivel de fila, lo que le permite aplicar reglas de autorización complejas directamente en la base de datos. Esto elimina la necesidad de filtrado en la capa de aplicación y garantiza la seguridad de los datos independientemente de cómo las aplicaciones accedan a la base de datos.

¿Por qué Seguridad a Nivel de Fila?

Problemas del Enfoque Tradicional

Filtrado en la capa de aplicación:

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

Problemas:
- Filtros fáciles de olvidar (vulnerabilidades de seguridad)
- Duplicación de código en toda la aplicación
- Sin protección para el acceso directo a la base de datos
- Requisitos de auditoría complejos

Beneficios de la Seguridad a Nivel de Fila

  • Aplicación automática: Políticas aplicadas de forma transparente
  • Reglas centralizadas: Lógica de seguridad en un solo lugar
  • Aislamiento multi-inquilino: Perfecto para aplicaciones SaaS
  • Cumplimiento de auditoría: Garantías de seguridad integradas
  • Rendimiento: Planificación de consultas eficiente

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 el código de la 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("SET app.user_id = %s", [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 Multi-Inquilino (Multi-Tenant)

Configuración Multi-Inquilino Completa

-- 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 la 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 de 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(
                    "SET app.tenant_id = %s",
                    [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('SET app.tenant_id = $1', [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 de Políticas Avanzados

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 aquellos 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'
        )
    );

Omitir RLS para Tareas Administrativas

Opción 1: Atributo de Rol BYPASSRLS

-- Otorgar acceso tipo superusuario
CREATE ROLE admin_user WITH LOGIN PASSWORD 'secure_password';
ALTER ROLE admin_user BYPASSRLS;

-- El admin puede ver todas las filas independientemente de las políticas

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: Desactivar RLS Temporalmente

-- Para scripts de mantenimiento
BEGIN;
    ALTER TABLE documents DISABLE ROW LEVEL SECURITY;
    -- Realizar operaciones administrativas
    UPDATE documents SET archived = true WHERE created_at < NOW() - INTERVAL '1 year';
    ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
COMMIT;

Tipos de Política: 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_user_id());

-- El usuario solo puede seleccionar (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_user_id());

-- El usuario solo puede insertar (INSERT) filas donde owner_id coincida

Políticas Combinadas

CREATE POLICY update_own ON documents
    FOR UPDATE
    USING (owner_id = current_user_id())       -- Solo puede actualizar sus propias filas
    WITH CHECK (owner_id = current_user_id()); -- No puede cambiar la propiedad

Consideraciones de Rendimiento

1. Indexar Columnas de Política

-- Crear índices para las columnas utilizadas en las 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. Evitar Subconsultas en las Políticas

Mal - Subconsulta ejecutada por cada fila:

CREATE POLICY slow_policy ON documents
    USING (
        id IN (SELECT document_id FROM shares WHERE user_id = current_user_id())
    );

Mejor - Usar EXISTS:

CREATE POLICY faster_policy ON documents
    USING (
        EXISTS (
            SELECT 1 FROM shares 
            WHERE document_id = documents.id 
              AND user_id = current_user_id()
        )
    );

Óptimo - Usar JOIN en la aplicación o vista materializada:

CREATE MATERIALIZED VIEW user_accessible_documents AS
SELECT d.*, s.user_id AS shared_with
FROM documents d
LEFT JOIN shares s ON d.id = s.document_id;

CREATE INDEX ON user_accessible_documents(shared_with);

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 las Políticas

-- Crear registro de auditoría para cambios de política
CREATE TABLE policy_audit_log (
    id SERIAL PRIMARY KEY,
    table_name VARCHAR(100),
    policy_name VARCHAR(100),
    action VARCHAR(20),
    changed_by VARCHAR(50),
    changed_at TIMESTAMP DEFAULT NOW()
);

-- Función de disparo para registrar cambios
CREATE OR REPLACE FUNCTION log_policy_changes()
RETURNS event_trigger AS $$
BEGIN
    INSERT INTO policy_audit_log (table_name, policy_name, action, changed_by)
    SELECT 
        objid::regclass::text,
        object_identity,
        'CREATED',
        current_user
    FROM pg_event_trigger_ddl_commands()
    WHERE command_tag = 'CREATE POLICY';
END;
$$ LANGUAGE plpgsql;

CREATE EVENT TRIGGER log_policy_create
    ON ddl_command_end
    WHEN TAG IN ('CREATE POLICY')
    EXECUTE FUNCTION log_policy_changes();

Probar la Efectividad de la Política

-- 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, true);
    PERFORM set_config('app.tenant_id', test_tenant_id::text, true);

    -- Devolver documentos accesibles
    RETURN QUERY SELECT id, documents.title FROM documents;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

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

Resolución de Problemas

Problema 1: Políticas No Aplicadas

Comprobar 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 configuración actual:

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 añadir índices

Problema 4: Denegación de Acceso Inesperada

Probar como usuario específico:

SET ROLE regular_user;
SET app.user_id = '123';

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

RESET ROLE;

Mejores Prácticas

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

Conclusión

La Seguridad a Nivel de Fila de PostgreSQL proporciona un control de acceso robusto y forzado por la base de datos:

  • Filtrado automático: Seguridad aplicada de forma transparente.
  • Aislamiento multi-inquilino: Ideal para aplicaciones SaaS.
  • Políticas flexibles: Admiten reglas de autorización complejas.
  • Seguridad centralizada: Lógica en la base de datos, no dispersa en el código.

Comience con un aislamiento de inquilinos simple, pruebe los impactos en el rendimiento y luego expándase a patrones de autorización más sofisticados según sea necesario.