Blog de Amazon Web Services (AWS)

Aislamiento de datos multi-usuario con seguridad a nivel fila de PostgreSQL

Por Michael Beardsley, Arquitecto Sr Partner Solutions en AWS

 

Aislar los datos de múltiples usuarios es una responsabilidad fundamental para los proveedores de Software como Servicio (Software as a Service, SaaS). Si uno de tus usuarios obtiene acceso a los datos de otro usuario, pierdes la confianza y puedes dañar permanentemente tu marca o, peor aún, perder tu negocio.

Con los riesgos tan grandes, es fundamental contar con un plan de aislamiento de datos efectivo. Las arquitecturas de múltiples usuarios proporcionan agilidad y ahorro de costos operativos al compartir recursos de almacenamiento de datos para todos los usuarios en lugar de replicar esos recursos para cada usuario. Sin embargo, debido a que es difícil imponer el aislamiento en un modelo compartido, puede comprometer su modelo de datos multiusuario y volver a la opción más costosa de una base de datos por usuario.

En un modelo de base de datos compartida, a menudo la única opción es confiar en sus desarrolladores de software para implementar las comprobaciones adecuadas con cada instrucción de SQL escrita. Al igual que otras preocupaciones de seguridad, desea aplicar políticas de aislamiento de datos de usuarios de una manera más centralizada que sea menos dependiente de la variabilidad diaria de su código fuente.

Esta publicación, esta dirigida a arquitectos y desarrolladores SaaS, que buscan una manera de lograr tanto los beneficios de una base de datos compartida para sus usuarios como la aplicación centralizada del aislamiento.

Opciones de particionamiento de datos

Hay tres modelos comunes de partición de datos utilizados en sistemas multi-usuario: silo, puente (bridge) y grupo (pool). Hay ventajas y desventajas de cómo cada modelo hace cumplir el aislamiento.

  • Silo: una instancia de base de datos separada por usuario proporciona la mayor separación a expensas de costos de infraestructura más altos y una configuración de usuario más complicada porque tendrá que crear y administrar una nueva instancia de base de datos para cada usuario que se incorpore a su oferta de SaaS.
  • Puente (Bridge): un segundo enfoque para particionar los datos del usuario es compartir la misma instancia de base de datos pero usar un esquema diferente para cada usuario. El modelo puede tener ahorros de costos debido al uso compartido de recursos, pero el mantenimiento y la configuración del usuario pueden ser bastante complicada.
  • Grupo (Pool) — El tercer modelo de partición utiliza tanto una instancia de base de datos compartida como un espacio de nombres(namespace). En este diseño, todos los datos del usuario se encuentran uno al lado del otro, pero cada tabla o vista contiene una clave de partición (generalmente el identificador de usuario), que se utiliza para filtrar los datos.

Un modelo agrupado(pooled model) ahorra al máximo los costos operativos y reduce el código de infraestructura y la sobrecarga de mantenimiento. Sin embargo, en este modelo puede ser más difícil de aplicar las políticas de acceso a datos, y comúnmente se implementa esperando que se utilice la cláusula WHERE correcta en cada instrucción de SQL.

Para obtener más información sobre el particionamiento de datos de varios usuarios, consulte el siguiente Documento Técnico de AWS SaaS Factory.

Seguridad a nivel de fila

Al centralizar la aplicación de las políticas de aislamiento en un sistema relación de base de datos (Relational Database Management System, RDBMS) a nivel de base de datos, disminuye la carga de sus desarrolladores de software. Esto le permite aprovechar los beneficios del modelo de tipo grupo y reducir el riesgo de acceso a datos entre usuarios

PostgreSQL 9.5 y versiones posteriores incluyen una funcionalidad llamada Seguridad a Nivel Fila (Row Level Security, RLS). Al definir políticas de seguridad en una tabla, estas políticas restringen las filas devueltas de esa tabla por las consultas SELECT o filas que se ven afectadas por los comandos INSERT, UPDATE y DELETE. Amazon Relational Database Service (RDS) admite RLS con los motores Amazon Aurora para PostgreSQL y RDS para PostgreSQL. Para obtener más información, consulte Políticas de seguridad de filas en el sitio web de PostgreSQL.

Las políticas RLS tienen un nombre y se aplican y eliminan en una tabla con instrucciones ALTER. Las políticas se definen con una cláusula USING que devuelve un valor booleano, que indica si se debe procesar una fila en la tabla. Puede aplicar varias políticas a una tabla al mismo tiempo para habilitar mecanismos de seguridad complejos. Adicionalmente, las políticas pueden cubrir todos los tipos de sentencias (SELECT, INSERT, UPDATE, DELETE), o puede tener políticas diferentes para modificaciones que para lecturas. Si utiliza políticas diferentes para SELECT versus modificaciones, debe incluir la cláusula WITH CHECK en su definición de política.

Se puede pensar en una política de RLS como una cláusula WHERE automatizada que el motor de base de datos administra por sí mismo.

Ejemplos de código

Esta publicación se complementa con un ejemplo completo publicado en la sección de ejemplos de AWS en GitHub. Para obtener más información, consulte el repositorio de GitHub de AWS SaaS Factory. Los ejemplos de código en esta publicación se obtienen del repositorio y no están destinados a ejecutarse de forma aislada.

Para crear una política de RLS como parte de la definición de su tabla, consulte el siguiente código:

-- Create a table for our tenants with indexes on the primary key and the tenant’s name
CREATE TABLE tenant (
    tenant_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    name VARCHAR(255) UNIQUE,
    status VARCHAR(64) CHECK (status IN ('active', 'suspended', 'disabled')),
    tier VARCHAR(64) CHECK (tier IN ('gold', 'silver', 'bronze'))
);

-- Create a table for users of a tenant
CREATE TABLE tenant_user (
    user_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenant (tenant_id) ON DELETE RESTRICT,
    email VARCHAR(255) NOT NULL UNIQUE,
    given_name VARCHAR(255) NOT NULL CHECK (given_name <> ''),
    family_name VARCHAR(255) NOT NULL CHECK (family_name <> '')
);

-- Turn on RLS
ALTER TABLE tenant ENABLE ROW LEVEL SECURITY;

-- Restrict read and write actions so tenants can only see their rows
-- Cast the UUID value in tenant_id to match the type current_user returns
-- This policy implies a WITH CHECK that matches the USING clause
CREATE POLICY tenant_isolation_policy ON tenant
USING (tenant_id::TEXT = current_user);

-- And do the same for the tenant users
ALTER TABLE tenant_user ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_user_isolation_policy ON tenant_user
USING (tenant_id::TEXT = current_user);

Considere usar secuencias numéricas para su clave primaria en lugar de valores aleatorios con un identificador único universal (universally unique identifier, UUID) para una mejor escalabilidad y rendimiento de conjuntos de datos grandes. Para obtener más información, consulte UUID-OSSP en el sitio web de PostgreSQL.

Uso de seguridad a nivel fila

Establecidas esas definiciones de tabla y políticas, asuma que hay un rol a nivel sistema que usted, como proveedor de SaaS, utiliza para provisionar la base de datos e incorporar nuevos usuarios. Aquí hay algunos ejemplos usando el cliente de línea de comandos para PostgreSQL, psql.

Si inicia sesión con el rol de sistema de proveedor SaaS que incorpora a los usuarios, puede ver todos sus registros de usuarios. Esto se debe a que, de forma predeterminada, el propietario de la tabla no está restringido por políticas de seguridad a menos que la tabla se modifique con FORCE ROW LEVEL SECURITY. Vea el siguiente código:

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
 69ad9212-f5ef-456d-a724-dd8ea3c80d61 | Tenant 2 | active | gold
(2 rows)

Y también podemos ver a todos los usuarios.

rls_multi_tenant=> SELECT tenant_id, user_id, given_name || ' ' || family_name AS name FROM tenant_user;
              tenant_id               |               user_id                |      name       
--------------------------------------+--------------------------------------+-----------------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | d9f7d636-69a0-40d4-96d9-d429d1e1cee3 | User 1 Tenant 1
 69ad9212-f5ef-456d-a724-dd8ea3c80d61 | eb7a503a-a7c6-44c0-9916-8df68dd96815 | User 1 Tenant 2
(2 rows)

Si inicia sesión en la base de datos con el rol del usuario Tenant 1, puede ver las políticas de seguridad a nivel fila en acción. Primero, confirme que ha iniciado sesión como el Usuario 1. Vea el siguiente código:

rls_multi_tenant=> SELECT current_user;
             current_user             
--------------------------------------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c
(1 row)

No hay ningún error o mensaje de la aplicación de la política de seguridad para las sentencias SELECT. Las filas que no coinciden con la instrucción USING de la política simplemente no existen en el conjunto de resultados. Vea el siguiente código:

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
(1 row)

Incluso si intentas acceder de manera forzada a la información de otro usuario, las políticas lo protegen. Vea el siguiente código:

rls_multi_tenant=> SELECT * FROM tenant WHERE tenant_id = '69ad9212-f5ef-456d-a724-dd8ea3c80d61'::UUID;
 tenant_id | name | status | tier 
-----------+------+--------+------
(0 rows)

Las políticas de sentencias de tipo UPDATE y DELETE se aplican de manera similar porque la política no devuelve filas coincidentes sobre las cuales actuar. Vea el siguiente código:

rls_multi_tenant=> UPDATE tenant_user SET given_name = 'Cross Tenant Access' WHERE user_id = 'eb7a503a-a7c6-44c0-9916-8df68dd96815'::UUID;
UPDATE 0

rls_multi_tenant=> DELETE FROM tenant WHERE tenant_id = '69ad9212-f5ef-456d-a724-dd8ea3c80d61'::UUID;
DELETE 0

Sin embargo, las sentencias de tipo INSERT que fallan en una política de seguridad devuelven un error.

rls_multi_tenant=> INSERT INTO tenant (name) VALUES ('Tenant 3');
ERROR:  new row violates row-level security policy for table "tenant"

Como Tenant 1, no puedes insertar un nuevo registro porque el valor de la columna tenant_id (auto-generado en este caso) no coincide con tu identidad. Si especifica su propio identificador  (ID) al ejecutar una inserción, se genera una violación de llave única.

Algunas consideraciones al usar seguridad a nivel fila

Los súper usuarios de PostgreSQL y cualquier rol creado con el atributo BYPASSRLS no están sujetos a políticas de tabla. Además, de forma predeterminada, el propietario de la tabla evita las políticas de RLS a menos que la tabla se altere con FORCE ROW LEVEL SECURITY. Es por ello que el rol a nivel de sistema que creó las tablas tenant y tenant_user puede acceder a todas las filas de los ejemplos anteriores.

Si el código de su aplicación se conecta a la base de datos con el mismo rol de PostgreSQL que el propietario de la tabla (generalmente el usuario que ejecuto las sentencias CREATE TABLE a menos que se modifique posteriormente), sus políticas de seguridad no están vigentes por defecto.

Por esta y otras razones de seguridad y monitoreo, debe tener su aplicación conectada a la base de datos con un usuario distinto al propietario de los objetos de la base de datos.

El segundo elemento a abordar es cómo define su cláusula USING. En los ejemplos anteriores, utilizó tenant_id = current_user, lo que significa que el nombre del rol PostgreSQL actualmente conectado debe coincidir con el valor de la columna tenant_id para que se procese la fila. Si usa este mecanismo, necesita crear un rol de PostgreSQL para cada usuario. Esto no es fácil de mantener y no es escalable.

Enfoque alternativo

Si no desea crear y mantener usuarios de PostgreSQL para cada uno de sus usuarios, aún puede usar un inicio de sesión compartida de PostgreSQL para su aplicación. Sin embargo, debe definir un parámetro en tiempo de ejecución para mantener el contexto del usuario actual de su aplicación. Asegúrese de que el usuario de inicio de sesión no es el propietario de la tabla o se definio con BYPASSRLS. Esta alternativa, que es muy escalable, se parece al siguiente código:

CREATE POLICY tenant_isolation_policy ON tenant
USING (tenant_id = current_setting('app.current_tenant')::UUID);

En lugar de comparar el usuario PostgreSQL usado para iniciar sesión con la columna tenant_id, usa la función incorporada current_setting para leer el valor de una variable de configuración llamada app.current_tenant (y convertir ese valor de texto a un valor UUID porque ese es el tipo definido de la columna tenant_id). Su variable debe de estar en el formato de un prefijo punto variable. Las variables que no incluyen un prefijo se definen en el archivo postgresql.conf, al que no se tiene acceso en su instancia de RDS.

Cuando define el valor de app.current_tenant, puede usar la función set_config o SET en el comando SQL para declarar un parámetro en tiempo de ejecución con alcance en la sesión de conexión de base de datos actual. Esta declaración debe ser hecha por su código de aplicación cuando cree la conexión de base de datos o recupere una existente de su grupo de conexiones de aplicaciones. Debido a que PostgreSQL aplica el alcance de estas variables en la sesión actual, es seguro utilizarlas en una aplicación multiconexión. Cada conexión tiene una copia separada de la variable y no puede acceder ni modificar los parámetros en tiempo de ejecución de ninguna otra conexión.

El uso de variables de sesión puede ser incompatible con los grupos de conexiones del lado del servidor, como PGBouncer. Asegúrese de revisar todas las implicaciones de su estrategia de agrupación de conexiones y probar si comparte el estado de la sesión.

Ejemplo de implementación

El siguiente ejemplo de código es una forma de establecer el parámetro en tiempo de ejecución. Aunque este código utiliza el lenguaje de programación Java, la mecánica es similar en su lenguaje de elección.

Con conexión de base de datos para Java (Java Database Connectivity, JDBC), el código usa una instancia javax.sql.DataSource y sobre escribe el método getConnection() para que cada vez que su aplicación (o su biblioteca de agrupación de conexiones) obtenga una conexión a la base de datos, se establezca el contexto del usuario adecuado y las políticas RLS en las tablas impongan el aislamiento de usuarios. Podría tener un aspecto similar al siguiente código:

// Every time the app asks the data source for a connection
// set the PostgreSQL session variable to the current tenant
// to enforce data isolation.
@Override
public Connection getConnection() throws SQLException {
    Connection connection = super.getConnection();
    try (Statement sql = connection.createStatement()) {
        sql.execute("SET app.current_tenant = '" + TenantContext.getTenant() + "'");
    }
    return connection;
}

Al igual que con el cliente psql de línea de comandos, el controlador JDBC para PostgreSQL no trata la activación de políticas RLS como una excepción. Si su consulta no cumple con la cláusula USING de la política, es como si las filas no existieran en la tabla y obtuviera un conjunto de resultados vacío.

Esta protección de seguridad a nivel de base de datos significa que cada sentencia de SQL que escriban sus desarrolladores tendrá el mismo aspecto, independientemente del contexto del usuario, y PostgreSQL impone el aislamiento por usted. Sus desarrolladores solo tienen que escribir la cláusula WHERE correcta para el caso de uso de negocio y no tienen que preocuparse por operar en una base de datos compartida y multi-usuario.

Asegúrese de probar minuciosamente las funciones, procedimientos, vistas y consultas anidadas complejas para asegurarse de que no haya restricciones o permisos no deseados debido a sus definiciones de política.

Conclusión

Al aprovechar la característica de seguridad a nivel fila de PostgreSQL, puede crear aplicaciones SaaS que utilicen un modelo de grupo para compartir recursos de bases de datos y también reducir el riesgo y la sobrecarga de hacer cumplir sus políticas de aislamiento. RLS le permite mover la aplicación aislada de políticas a un lugar centralizado en el proceso de fondo (backend) de PostgreSQL, lejos de la codificación diaria de su desarrollador.

El modelo de tipo grupo ayuda a evitar los mayores costos de recursos duplicados para cada usuario y el código de infraestructura especializado requerido para configurar y mantener esas copias. Debido a que tiene menos recursos y todos sus usuarios están en un solo lugar, una sola vista operativa de su plataforma es más sencilla de implementar. Este modelo también puede simplificar el proceso de respaldo y restauración de la base de datos porque tiene menos partes móviles.

Si actualmente está utilizando un modelo de silo o puente para el aislamiento de datos de sus usuarios, podría ser el momento de buscar RLS para un enfoque más ágil y rentable.

Para obtener más información sobre los ejemplos de esta publicación, consulte el repositorio de GitHub de AWS SaaS Factory. El repositorio contiene una aplicación de ejemplo y recursos de AWS CloudFormation para aprovisionar automáticamente la infraestructura necesaria en su cuenta de AWS.

Para obtener más información sobre la creación y entrega de soluciones SaaS en AWS, consulte AWS SaaS Factory y el blog de AWS SaaS Factory.

 

Este artículo fue traducido del Blog de AWS en Inglés.


Acerca del autor

Michael Beardsley es un Arquitecto Sr Partner Solutions con Amazon Web Services.

 

 

 

 

Traductor

Sergio Nuñez es especialista en bases de datos con el equipo de aceleración de la nube de Amazon Web Services. Sergio se enfoca en liderar los esfuerzos de migración de bases de datos a AWS, así como proporcionar orientación técnica que incluye optimización de costos, monitoreo y experiencia en modernización a los clientes de Amazon