Control de acceso jerárquico basado en roles para Node.js

Control de acceso basado en roles jerárquicos para Node.js / HRBAC (Hierarchical Role Based Access Control)

#nodejs#pentesting
hrbac-hierarchical-role-based-access-control

La mayoría de las aplicaciones web dependen de algún tipo de control de acceso para evitar que los usuarios accedan a información que no está destinada a ellos.

Hemos visto bastantes publicaciones en blogs sobre sobre los mecanismos de seguridad para aplicaciones web.

En este post iremos más allá de la teoría y tomaremos un enfoque más práctico construyendo el módulo RBAC desde cero para que podamos revisar los privilegios de nuestros usuarios. Nuestro objetivo, como de costumbre, es hacer que la web sea un lugar más seguro para todos.

Comenzaremos con una breve recapitulación sobre la teoría de control de acceso seguida de pasos incrementales para construirla.

ACL / Access Control List, la Lista de control de acceso es una implementación de control de acceso, generalmente representada como una tabla de privilegios.

| Name | Read | Write | Publish | ------------- | ------------- | | Esteban | 1 | 1 | 1 | | Jesús | 1 | 1 | 0 | | Martin | 1 | 0 | 0 |

En esta tabla podemos ver cómo cada usuario es una fila y tiene privilegios específicos asignados a ellos. Tras la verificación de control de acceso, la fila del usuario y la columna en cuestión se verifican de forma cruzada; esto determina si este usuario tiene acceso o no.

RBAC or Role Based Access Control

El control de acceso basado en roles es un método de control de acceso donde los usuarios reciben roles y los roles determinan los privilegios que tienen. Por lo general, se describe como un árbol o diagrama, ya que los roles pueden heredar los accesos de sus roles principales. Entonces nuestra tabla anterior de ACL podría verse más o menos así:

(((Manager can publish) Writer can write  ) Guest can read )

Estos son los entendimientos comunes de ACL y RBAC y ambos son incorrectos. Y he aquí por qué:

En primer lugar, ACL no es un modelo de control de acceso, sino un tipo de implementación. A menudo se confunde con IBAC (Control de acceso basado en identidad) donde cada individuo tiene sus derechos de acceso determinados por separado, en función de su identidad.

Eso se parece mucho al ACL que describimos anteriormente. Sin embargo, las variaciones de ACL como ACLg también se pueden usar para implementar el modelo de acceso RBAC. Simplemente sustituimos al individuo por un grupo. Como resultado, terminamos con:

| Name | Read | Write | Publish | ------------- | ------------- | | managers | 1 | 1 | 1 | | writers | 1 | 1 | 0 | | guests | 1 | 0 | 0 |

Esto significa que ACLg (g significa agrupado) es equivalente a RBACm (m significa mínimo). Es posible que se pregunte dónde está la jerarquía en este modelo. Bueno, no hay ninguno. RBAC no tiene jerarquía escrita en la definición básica; es un extra agregado en un modelo referido a HRBAC (Hierarchical Role Based Access Control) / (Control de acceso basado en roles jerárquicos).

Entonces, para recapitular: ACL no es un modelo de control de acceso, sino que un tipo de implementación y RBAC no tiene jerarquía según la definición de línea base.

Conozca su control de acceso

Ahora que hemos determinado que tenemos algunos conceptos erróneos sobre los métodos de control de acceso más populares, echemos un vistazo a los tipos de control de acceso que realmente existen. Al final de esta sección, debe tener una visión general de los métodos comunes de control de acceso y cómo difieren.

MAC/DAC (Mandatory/Discretionary Access Control), control de acceso obligatorio / discrecional: aunque los métodos de control de acceso son completamente separados, los agrupé, ya que estos dos solo difieren en un aspecto importante. Ambos se enfocan en el objeto de datos como el centro de los derechos de acceso. El método de control de acceso discrecional se puede ver más fácilmente en los sistemas UNIX, donde el propietario de cualquier archivo dado tiene control sobre a quién otorgar acceso. Los derechos de acceso son a su discreción, de ahí el nombre. MAC también se centra en el objeto de datos como la base de los derechos de acceso, sin embargo, los derechos no están determinados por el propietario sino por la sensibilidad del objeto de datos. Este método se ve con mayor frecuencia en los sistemas gubernamentales o militares debido a los altos costos de implementación.

- Jesús crea /file.txt
- El archivo está configurado para leer solo para administrador
- Jesús no tiene acceso a escritura
- Las escrituras de acceso están determinadas por una política
- Control de acceso obligatorio (MAC)

Mandatory Access Control (MAC) (Control de acceso obligatorio)

En resumen, MAC y DAC se enfocan en el objeto o archivo de datos, mientras que DAC me permite (el propietario del archivo) determinar quién tiene acceso. En MAC, sin embargo, los derechos de acceso los determina el administrador o la regla general.

- Jesús crea /files.txt
- Jesús decide que es un archivo público
- El archivo se establece como público
- Es el propietario del archivo, por lo que obtiene lo que quiere
- Los derechos de acceso están determinados por el propietario

Discretionary Access Control (DAC) (Control de acceso discrecional)

IBAC (Identity Based Access Control) / Control de acceso basado en identidad: este método se centra en la identidad del usuario como base de los privilegios. A cada individuo se le otorgan derechos de acceso específicos para cada operación. Los beneficios son una gran granularidad al asignar derechos y simplicidad en sistemas con pocos usuarios. Sin embargo, a medida que los sistemas crecen en número de usuarios, generalmente se vuelve difícil de administrar.

- Jesús intenta abrir /log.txt
- Se comprueba si Jesús puede acceder al archivo: /log.txt
- Sí, Jesús puede abrirlo

Identity Based Access Control (IBAC) / Control de acceso basado en identidad

RBAC (Role Based Access Control) / Control de acceso basado en roles: Intenta resolver las limitaciones de la gestión de IBAC en sistemas grandes al imitar las necesidades del mundo real más de cerca. Los privilegios operativos se agrupan en roles y a cada usuario se le asigna un rol. El papel, en lugar del individuo, es la base de las verificaciones de acceso. A menudo se implementa en un modelo jerárquico, donde las funciones de nivel superior heredan los privilegios de los niveles inferiores. RBAC sacrifica la granularidad para una mayor capacidad de mantenimiento en sistemas con muchos usuarios.

- Jesús: user - Intenta abrir /log.txt
- Compruebe si un usuario puede acceder al archivo: /log.txt
- Sí, user puede
- entonces... Jesús puede controlar el acceso basado en roles (RBAC)

Role Based Access Control (RBAC) / Control de acceso basado en roles

Hierarchical Role Based Access Control (HRBAC) / Control de acceso jerárquico basado en roles

ABAC (Attribute Based Access Control) / control de acceso basado en atributos: es una evolución de RBAC que intenta resolver algunas deficiencias en situaciones específicas. En sistemas donde hay muchos atributos que separan el acceso a recursos internos (es decir, el usuario ha pasado algunas pruebas y ha sido educado en el uso de esta parte del sistema, etc.), el uso del modelo RBAC daría lugar a una necesidad de definir todos los roles que separan a los usuarios en función de sus atributos. ABAC tiene como objetivo resolver este problema proporcionando un marco para definir los derechos de acceso en función de las diversas propiedades de un usuario.

https://cdn-images-1.medium.com/max/1600/1*Jo8yL1GWZE1CEEyekETEIA.gif Entonces... Jesús puede Control de acceso basado en atributos / Attribute Based Access Control (ABAC)

Jesús 
    -name: joe
    -role: user
    -trained: true
    -paying: true

Intenta abrir /log.txt

Verifica si Jesús cumple con los requisitos
    -name: joe
    -role: user
    -trained: true
    -paying: true

Sí, Jesús coincide

Attributes Based Access Control (ABAC) / Control de acceso basado en atributos:

Espero que hayas encontrado estos matices detallados útiles? En la siguiente sección veremos más de cerca el método de control de acceso más popular de la web, RBAC.

Detalles de RBAC

RBAC (Control de acceso basado en roles) es un método de control de acceso donde a cada identidad se le asigna un rol y los roles determinan qué derechos de acceso tiene la identidad. Esto se opone al IBAC, donde cada identidad tiene una asignación de privilegios separada. RBAC pierde cierta granularidad en comparación con IBAC, sin embargo, gana una mejor capacidad de administración en entornos con grandes cantidades de usuarios.

El RBAC generalmente se implementa como una Jerarquía de roles (HRBAC). Esto permite que los roles hereden privilegios de otros roles, lo que a su vez facilita agregar nuevos privilegios operativos a todo el árbol.

Imaginemos una aplicación en la que tenemos tres roles: 'Invitado', 'Escritor', 'Administrador'. A continuación, podemos ilustrar la jerarquía de funciones de la siguiente manera:

((( Manager can publish ) Writer can write ) Guest can read )

Si ahora queremos agregar una operación de edición, que está permitida tanto para el escritor como para el administrador, entonces todo lo que tenemos que hacer es extender el rol del escritor:

((( Manager can publish ) Writer can write can edit ) Guest can read )

La definición de roles también es una característica bienvenida durante el desarrollo de la aplicación. Al separar previamente a los usuarios en categorías bien definidas, somos más capaces de modelar la seguridad de la aplicación.

En resumen, RBAC es el método de control de acceso estándar de facto para la mayoría de las aplicaciones web. Principalmente porque la creación de una aplicación web significa que espera manejar una gran cantidad de usuarios: miles, millones e incluso miles de millones. La implementación de IBAC en esta situación resultaría en una enorme duplicación de datos para los derechos de acceso.

Ahora que estamos más familiarizados con la lógica detrás de RBAC, podemos continuar con nuestro plan para construir un módulo de RBAC.

Comenzamos

La lógica de un modelo básico de RBAC es simple: usted define una cantidad de roles y cada función tiene privilegios asignados. Al verificar el acceso, verifica si la función tiene acceso y eso es todo.

Entonces nuestro ejemplo de antes se puede resumir en dos tablas:

https://cdn-images-1.medium.com/max/1600/1*PcnPeCVGHjDcc7EWlS_5Ow.png

| Name | Read | Write | Publish | ------------- | ------------- | | managers | 1 | 1 | 1 | | writers | 1 | 1 | 0 | | guests | 1 | 0 | 0 |

| Name | Role | Karl | manager | John | writer | Jack | guest

https://cdn-images-1.medium.com/max/1600/1*PcnPeCVGHjDcc7EWlS_5Ow.png

| Name | Read | Write | Publish | ------------- | ------------- | | managers | 1 | 1 | 1 | | writers | 1 | 1 | 0 | | guests | 1 | 0 | 0 |

| Name | Role | Karl | manager | John | writer | Jack | guest

Podemos lograr este modelo bastante fácilmente en JavaScript: creemos un modelo de los roles y una función para verificarlos.

let roles = {
    manager: {
        can: ['read', 'write', 'publish']
    },
    writer: {
        can: ['read', 'write']
    },
    guest: {
        can: ['read']
    }
}

function can(role, operation) {
    return roles[role] && roles[role].can.indexOf(operation) !== -1;
}

Y ahora tenemos un sistema de roles muy simple. Vamos a darle una forma configurable y reutilizable a la manera de una clase.

class RBAC {
    constructor(roles) {
        if(typeof roles !== 'object') {
            throw new TypeError('Expected an object as input');
        }
        this.roles = roles;
    }

    can(role, operation) {
        return this.roles[role] && this.roles[role].can.indexOf(operation) !== -1;
    }
}

module.exports = RBAC;

Esto nos deja con un módulo muy simple para definir y verificar roles. No nos detengamos aquí: agregaremos jerarquía al modelo para que podamos administrar los roles más fácilmente al agregar nuevas operaciones al sistema.

De esta forma, no es necesario definir los derechos de cada operación para cada función por separado.

It’ll allow the user to represent a list of child roles, where to inherit permissions from.

let roles = {
    manager: {
        can: ['publish'],
        inherits: ['writer']
    },
    writer: {
        can: ['write'],
        inherits: ['guest']
    },
    guest: {
        can: ['read']
    }
}

Y luego tenemos que reescribir la funcionalidad de verificación de acceso. En el modelo HRBAC, la verificación de acceso comienza con el rol actual, comprueba si tiene acceso, si no, se mueve hacia el padre y lo vuelve a verificar. Esto sucede hasta que se encuentra un permiso o no hay más padres para verificar. Entonces, podemos reescribir nuestra funcionalidad de verificación para usar lógica recursiva:

can(role, operation) {
    // Check if role exists
    if(!this.roles[role]) {
        return false;
    }
    let $role = this.roles[role];
    // Check if this role has access
    if($role.can.indexOf(operation) !== -1) {
        return true;
    }
    // Check if there are any parents
    if(!$role.inherits || $role.inherits.length < 1) {
        return false;
    }

    // Check child roles until one returns true or all return false
    return $role.inherits.some(childRole => this.can(childRole, operation));
}

Ahora tenemos roles, herencia y una función para unirlos. Casi terminado, pero aún no del todo. Todavía hay casos de uso real que no hemos tenido en cuenta. Permítanme darles un ejemplo basado en una plataforma de blogs donde un escritor puede crear una publicación de blog y luego abrirla para su edición, ¿debería la función de escritor también reescribir cada publicación en el sistema? Probablemente no. Primero debemos verificar si son los propietarios de la publicación. Pero, ¿cómo podemos escribir eso en una definición reutilizable: funciones? Para responder a esto, permitamos que las operaciones definan funciones que deben pasar.

So to extend our existing model of roles:

let roles = {
    manager: {
        can: ['publish'],
        inherits: ['writer']
    },
    writer: {
        can: ['write', {
            name: 'edit',
            when: function (params) {
                return params.user.id === params.post.owner;
            }
        }],
        inherits: ['guest']
    },
    guest: {
        can: ['read']
    }
}

Pero ahora nuestra función de verificación también debe ser reescrita: ya no podemos usar indexOf. Creemos una función para normalizar nuestra entrada para un mejor uso interno:

class RBAC {
    constructor(opts) {
        this.init(opts);
    }

    init(roles) {
        if(typeof roles !== 'object') {
            throw new TypeError('Expected an object as input');
        }

        this.roles = roles;
        let map = {};
        Object.keys(roles).forEach(role => {
            map[role] = {
                can: {}
            };
            if(roles[role].inherits) {
                map[role].inherits = roles[role].inherits;
            }

            roles[role].can.forEach(operation => {
                if(typeof operation === 'string') {
                    map[role].can[operation] = 1;
                } else if(typeof operation.name === 'string'
                    && typeof operation.when === 'function') {

                    map[role].can[operation.name] = operation.when;
                }
                // Ignore definitions we don't understand
            });

        });

        this.roles = map;
    }

    // ... //
}

Y ahora podemos usar el mapa que creamos en nuestra función de verificación:

can(role, operation, params) {
    // Check if role exists
    if(!this.roles[role]) {
        return false;
    }
    let $role = this.roles[role];
    // Check if this role has this operation
    if($role.can[operation]) {
        // Not a function so we are good
        if(typeof $role.can[operation] !== 'function') {
            return true;
        }
        // If the function check passes return true
        if($role.can[operation](params)) {
            return true;
        }
    }

    // Check if there are any parents
    if(!$role.inherits || $role.inherits.length < 1) {
        return false;
    }

    // Check child roles until one returns true or all return false
    return $role.inherits.some(childRole => this.can(childRole, operation, params));
}

¡Increíble! Ahora tenemos la clase RBAC que podemos usar para verificar nuestro modelo de jerarquía definido. Además, también podemos definir funciones para realizar comprobaciones dinámicas de acceso específico:

RBAC.can('writer', 'edit', {user: user, post: post});

Aún no hemos terminado. No olvidemos que estamos tratando con Node.js, por lo que las soluciones sincrónicas no son la mejor manera de hacerlo; necesitamos asincronización para que podamos instanciar la clase con la información que se encuentra en la base de datos. O podemos querer que nuestro control de acceso busque algo desde el sistema de archivos, otra API o en otro lugar. El punto es - lo necesitamos.

Podemos proporcionar esto de dos maneras: con promesas o devoluciones de llamadas. Como queremos apoyar ambos estilos, implementemos ambos. Sin embargo, la transformación de Promesa a devolución de llamada es mucho más fácil que viceversa, por lo que usaremos Promesas internamente.

Comenzaremos nuestra actualización con la función de verificación. Usemos el módulo Q para proporcionar compatibilidad hacia atrás. Podemos simplemente envolver los contenidos de nuestra función en un constructor de promesa:

let Q = require('q');

// ... //

can(role, operation, params) {
    return Q.Promise((resolve, reject) => {
        // our function
        // ... //
    });
}

// ... //

A continuación, podemos gestionar las devoluciones de llamadas vinculando de forma opcional a los controladores con nuestra promesa.

let Q = require('q');
can(role, operation, params, cb) {
    let callback = cb || () => {};
    return Q.Promise((resolvePromise, rejectPromise) => {

        // Collect resolve handling
        function resolve(value) {
            resolvePromise(result);
            callback(undefined, result);
        }

        // Collect error handling
        function reject(err) {
            rejectPromise(err);
            callback(err);
        }

        // our function
        // ... //

    });
}

Podemos manejar internamente los eventos de resolución / rechazo, especificando una devolución de llamada nosotros mismos

$role.can[operation](params, function (err, result) { 
    if(err) { 
        return reject(err); 
    } 
    if(!result) { 
        return reject(false); 
    } 
    resolve(true); 
});

Y podemos manejar la herencia creando una nueva promesa. Uno que se resuelve cuando una de las promesas del niño se resuelve, también conocido como Q.any.

return Q.any($role.inherits.map(child => this.can(child, operation, params)))
    .then(resolve, reject);

Después de agregar algunas comprobaciones de tipo, nuestra función puede podría ser algo como esto:

can(role, operation, params, cb) {

    if(typeof params === 'function') {
        cb = params;
        params = undefined;
    }

    let callback = cb || () => {};

    return Q.Promise((resolve, reject) => {

        // Collect resolve handling
        function resolve(value) {
            resolvePromise(result);
            callback(undefined, result);
        }

        // Collect error handling
        function reject(err) {
            rejectPromise(err);
            callback(err);
        }

        if (typeof role !== 'string') {
            throw new TypeError('Expected first parameter to be string : role');
        }

        if (typeof operation !== 'string') {
            throw new TypeError('Expected second parameter to be string : operation');
        }

        let $role = $this.roles[role];

        if (!$role) {
            throw new Error('Undefined role');
        }

        // IF this operation is not defined at current level try higher
        if (!$role.can[operation]) {
            // If no parents reject
            if (!$role.inherits) {
                return reject(false);
            }
            // Return if any parent resolves true or all reject
            return Q.any($role.inherits.map(parent => this.can(parent, operation, params)))
                .then(resolve, reject);
        }

        // We have the operation resolve
        if ($role.can[operation] === 1) {
            return resolve(true);
        }

        // Operation is conditional, run async function
        if (typeof $role.can[operation] === 'function') {
            $role.can[operation](params, function (err, result) {
                if(err) {
                    return reject(err);
                }
                if(!result) {
                    return reject(false);
                }
                resolve(true);
            });
            return;
        }
        // No operation reject as false
        reject(false);
    });
};

Ahora casi hemos terminado. Lo último que queremos respaldar es la carga asincrónica de las definiciones de roles. Esto significa que tenemos que manejar la inicialización. La forma más fácil de aceptar una función como una entrada que puede devolver el objeto de configuración después de obtenerlo en alguna parte. Para hacer esto, agreguemos una marca al comienzo de la función init y almacenamos el estado de resolución en una variable:

// If opts is a function execute for async loading 
if(typeof roles === 'function') { 
  this._init = Q.nfcall(roles)
                .then(data => this.init(data)); 
  return; 
}

Y agregue $ this._inited = true antes de la declaración de devolución. Ahora podemos verificar al comienzo de la función can si hemos logrado configurar nuestros roles y actuar en consecuencia:

Ahora podemos verificar al comienzo de la función can si hemos logrado configurar nuestros roles y actuar en consecuencia

// If not inited then wait until init finishes 
if(!this._inited) { 
  return this._init
             .then(() => this.can(role, operation, params, cb)); 
}

En este post vimos varios métodos de control de acceso y desacredité algunos conceptos erróneos comunes en el camino. Ahora debería conocer las metodologías clave y cómo difieren.