Manejo de permisos de usuario en JavaScript

Entonces, ha estado trabajando en esta nueva y elegante aplicación web. Ya sea una aplicación de recetas, un administrador de documentos o incluso su nube privada, ahora ha llegado al punto de trabajar con usuarios y permisos. Tome el administrador de documentos como ejemplo: no solo quiere administradores; tal vez desee invitar a personas con acceso de solo lectura o personas que puedan editar sus archivos, pero no eliminarlos. ¿Cómo maneja esa lógica en el front-end sin saturar su código con demasiadas condiciones y verificaciones complicadas?

En este artículo, repasaremos una implementación de ejemplo sobre cómo podría manejar este tipo de situaciones de una manera elegante y limpia. Tómelo con un grano de sal: sus necesidades pueden diferir, pero espero que pueda obtener algunas ideas de él.

Supongamos que ya creó el back-end, agregó una tabla para todos los usuarios en su base de datos y tal vez proporcionó una columna o propiedad dedicada para los roles. Los detalles de implementación dependen totalmente de usted (según su pila y preferencia). Por el bien de esta demostración, utilicemos las siguientes funciones:

  • Administrador: puede hacer cualquier cosa, como crear, eliminar y editar documentos propios o ajenos.
  • Editor: puede crear, ver y editar archivos, pero no eliminarlos.
  • Invitado: puede ver archivos, así de simple.

Como la mayoría de las aplicaciones web modernas, su aplicación puede usar una API RESTful para comunicarse con el back-end, así que usemos este escenario para la demostración. Incluso si opta por algo diferente como GraphQL o la representación del lado del servidor, aún puede aplicar el mismo patrón que vamos a ver.

La clave es devolver la función (o el permiso, si prefiere ese nombre) del usuario que ha iniciado sesión actualmente al obtener algunos datos.

{
  id: 1,
  title: "My First Document",
  authorId: 742,
  accessLevel: "ADMIN",
  content: {...}
}

Aquí, obtenemos un documento con algunas propiedades, incluida una propiedad llamada accessLevel para el rol del usuario. Así es como sabemos lo que el usuario que ha iniciado sesión tiene permitido o no hacer. Nuestro próximo trabajo es agregar algo de lógica en la interfaz para asegurarnos de que los invitados no vean cosas que se supone que no deben ver, y viceversa.

Idealmente, no solo confía en la interfaz para verificar los permisos. Alguien con experiencia en tecnologías web aún podría enviar una solicitud sin IU al servidor con la intención de manipular datos, por lo tanto, su backend también debería verificar las cosas.

Por cierto, este patrón es independiente del marco; no importa si trabaja con React, Vue o incluso algún JavaScript salvaje de Vanilla.

Definiendo constantes

El primer paso (opcional, pero muy recomendable) es crear algunas constantes. Estos serán objetos simples que contienen todas las acciones, roles y otras partes importantes de las que podría consistir la aplicación. Me gusta ponerlos en un archivo dedicado, tal vez llamarlo constants.js:

const actions = {
  MODIFY_FILE: "MODIFY_FILE",
  VIEW_FILE: "VIEW_FILE",
  DELETE_FILE: "DELETE_FILE",
  CREATE_FILE: "CREATE_FILE"
};

const roles = {
  ADMIN: "ADMIN",
  EDITOR: "EDITOR",
  GUEST: "GUEST"
};

export { actions, roles };

Si tiene la ventaja de usar TypeScript, puede usar enumeraciones para obtener una sintaxis un poco más limpia.

Crear una colección de constantes para sus acciones y roles tiene algunas ventajas:

  • Una sola fuente de verdad. En lugar de revisar toda la base de código, simplemente abre constants.js para ver qué es posible dentro de tu aplicación. Este enfoque también es muy extensible, por ejemplo, cuando agrega o elimina acciones.
  • Sin errores tipográficos. En lugar de escribir manualmente un rol o acción cada vez, lo que lo hace propenso a errores tipográficos y sesiones de depuración desagradables, importa el objeto y, gracias a la magia de su editor favorito, obtiene sugerencias y autocompletado de forma gratuita. Si aún escribe mal un nombre, ESLint o alguna otra herramienta probablemente le gritará hasta que lo arregle.
  • Documentación. ¿Estás trabajando en equipo? Los nuevos miembros del equipo apreciarán la simplicidad de no tener que revisar toneladas de archivos para comprender qué permisos o acciones existen. También se puede documentar fácilmente con JSDoc.

Usar estas constantes es bastante sencillo; importarlos y usarlos así:

import { actions } from "./constants.js";

console.log(actions.CREATE_FILE);

Definición de permisos

Vamos a la parte emocionante: modelar una estructura de datos para asignar nuestras acciones a los roles. Hay muchas formas de resolver este problema, pero la siguiente es la que más me gusta. Creemos un nuevo archivo, llamémoslo permissions.js y coloquemos un código dentro:

import { actions, roles } from "./constants.js";

const mappings = new Map();

mappings.set(actions.MODIFY_FILE, [roles.ADMIN, roles.EDITOR]);
mappings.set(actions.VIEW_FILE, [roles.ADMIN, roles.EDITOR, roles.GUEST]);
mappings.set(actions.DELETE_FILE, [roles.ADMIN]);
mappings.set(actions.CREATE_FILE, [roles.ADMIN, roles.EDITOR]);

Repasemos esto, paso a paso:

  • Primero, necesitamos importar nuestras constantes.
  • Luego creamos un nuevo mapa de JavaScript, llamado mapeos. Podríamos haber optado por cualquier otra estructura de datos, como objetos, matrices, lo que sea. Me gusta usar Maps, ya que ofrecen algunos métodos útiles, como .has (), .get (), etc.
  • A continuación, agregamos (o más bien configuramos) una nueva entrada para cada acción que tiene nuestra aplicación. La acción sirve como clave, mediante la cual obtenemos los roles necesarios para ejecutar dicha acción. En cuanto al valor, definimos una serie de roles necesarios.

Este enfoque puede parecer extraño al principio (a mí me pareció), pero aprendí a apreciarlo con el tiempo. Los beneficios son evidentes, especialmente en aplicaciones más grandes con toneladas de acciones y roles:

  • Nuevamente, solo una fuente de verdad. ¿Necesita saber qué roles se requieren para editar un archivo? No hay problema, diríjase a permissions.js y busque la entrada.
  • Modificar la lógica empresarial es sorprendentemente sencillo. Supongamos que su gerente de producto decide que, a partir de mañana, los editores pueden eliminar archivos; simplemente agregue su función a la entrada DELETE_FILE y termine el día. Lo mismo ocurre con la adición de nuevos roles: agregue más entradas a la variable de asignaciones y estará listo.
  • Comprobable. Puede usar pruebas de instantáneas para asegurarse de que nada cambie inesperadamente dentro de estas asignaciones. También es más claro durante las revisiones de código.

El ejemplo anterior es bastante simple y podría ampliarse para cubrir casos más complicados. Si tiene diferentes tipos de archivos con diferentes roles de acceso, por ejemplo. Más sobre eso al final de este artículo.

Verificación de permisos en la interfaz de usuario

Definimos todas nuestras acciones y roles y creamos un mapa que explica quién tiene permitido hacer qué. Es hora de implementar una función para que la usemos en nuestra interfaz de usuario para verificar esos roles.

Al crear un comportamiento tan nuevo, siempre me gusta comenzar con el aspecto que debería tener la API. Luego, implemento la lógica real detrás de esa API.

Digamos que tenemos un componente React que muestra un menú desplegable:

function Dropdown() {
  return (
    <ul>
      <li><button type="button">Refresh</button><li>
      <li><button type="button">Rename</button><li>
      <li><button type="button">Duplicate</button><li>
      <li><button type="button">Delete</button><li>
    </ul>
  );
}

Obviamente, no queremos que los invitados vean ni hagan clic en la opción «Eliminar» o «Cambiar nombre», pero queremos que vean «Actualizar». Por otro lado, los editores deberían ver todos menos «Eliminar». Imagino alguna API como esta:

hasPermission(file, actions.DELETE_FILE);

El primer argumento es el archivo en sí, como lo obtiene nuestra API REST. Debe contener la propiedad accessLevel de antes, que puede ser ADMIN, EDITOR o GUEST. Dado que el mismo usuario puede tener diferentes permisos en diferentes archivos, siempre debemos proporcionar ese argumento.

En cuanto al segundo argumento, pasamos una acción, como eliminar el archivo. Luego, la función debe devolver un valor booleano verdadero si el usuario que ha iniciado sesión actualmente tiene permisos para esa acción, o falso si no los tiene.

import hasPermission from "./permissions.js";
import { actions } from "./constants.js";

function Dropdown() {
  return (
    <ul>
      {hasPermission(file, actions.VIEW_FILE) && (
        <li><button type="button">Refresh</button></li>
      )}
      {hasPermission(file, actions.MODIFY_FILE) && (
        <li><button type="button">Rename</button></li>
      )}
      {hasPermission(file, actions.CREATE_FILE) && (
        <li><button type="button">Duplicate</button></li>
      )}
      {hasPermission(file, actions.DELETE_FILE) && (
        <li><button type="button">Delete</button></li>
      )}
    </ul>
  );
}

Es posible que desee encontrar un nombre de función menos detallado o tal vez incluso una forma diferente de implementar toda la lógica (me viene a la mente currying), pero para mí, esto ha hecho un buen trabajo, incluso en aplicaciones con permisos súper complejos. Claro, el JSX parece más desordenado, pero ese es un pequeño precio a pagar. El uso constante de este patrón en toda la aplicación hace que los permisos sean mucho más limpios y más intuitivos de entender.

En caso de que aún no esté convencido, veamos cómo se vería sin el ayudante hasPermission:

return (
  <ul>
    {['ADMIN', 'EDITOR', 'GUEST'].includes(file.accessLevel) && (
      <li><button type="button">Refresh</button></li>
    )}
    {['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
      <li><button type="button">Rename</button></li>
    )}
    {['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
      <li><button type="button">Duplicate</button></li>
    )}
    {file.accessLevel == "ADMIN" && (
      <li><button type="button">Delete</button></li>
    )}
  </ul>
);

Podría decir que esto no se ve tan mal, pero piense en lo que sucede si se agrega más lógica, como verificaciones de licencias o permisos más granulares. Las cosas tienden a salirse de control rápidamente en nuestra profesión.

¿Se pregunta por qué necesitamos la primera verificación de permisos cuando todos pueden ver el botón «Actualizar» de todos modos? Me gusta tenerlo ahí porque nunca se sabe lo que podría cambiar en el futuro. Es posible que se introduzca un nuevo rol que ni siquiera vea el botón. En ese caso, solo tiene que actualizar su permissions.js y dejar el componente solo, lo que resulta en un compromiso de Git más limpio y menos posibilidades de equivocarse.

Implementando el verificador de permisos

Finalmente, es hora de implementar la función que lo une todo: acciones, roles y la interfaz de usuario. La implementación es bastante sencilla:

import mappings from "./permissions.js";

function hasPermission(file, action) {
  if (!file?.accessLevel) {
    return false;
  }

  if (mappings.has(action)) {
    return mappings.get(action).includes(file.accessLevel);
  }

  return false;
}

export default hasPermission;
export { actions, roles };

Puede poner el código anterior en un archivo separado o incluso dentro de permissions.js. Yo personalmente los guardo juntos en un archivo pero, oye, no te estoy diciendo cómo vivir tu vida. 🙂

Analicemos lo que está sucediendo aquí:

  1. Definimos una nueva función, hasPermission, usando la misma firma de API que decidimos anteriormente. Toma el archivo (que viene del back-end) y la acción que queremos realizar.
  2. A prueba de fallas, si, por alguna razón, el archivo es nulo o no contiene una propiedad accessLevel, devolvemos falso. Es mejor tener mucho cuidado de no exponer información «secreta» al usuario causada por una falla o algún error en el código.
  3. Llegando al núcleo, comprobamos si las asignaciones contienen la acción que estamos buscando. Si es así, podemos obtener su valor de manera segura (recuerde, es una variedad de roles) y verificar si nuestro usuario actualmente conectado tiene el rol requerido para esa acción. Esto devuelve verdadero o falso.
  4. Finalmente, si las asignaciones no contienen la acción que estamos buscando (podría ser un error en el código o una falla nuevamente), devolvemos falso para ser más seguro.
  5. En las dos últimas líneas, no solo exportamos la función hasPermission, sino que también reexportamos nuestras constantes para la conveniencia del desarrollador. De esa forma, podemos importar todas las utilidades en una línea.
import hasPermission, { actions } from "./permissions.js";

Más casos de uso

El código que se muestra es bastante simple para fines de demostración. Aún así, puede tomarlo como base para su aplicación y darle forma en consecuencia. Creo que es un buen punto de partida para que cualquier aplicación basada en JavaScript implemente roles y permisos de usuario.

Con un poco de refactorización, incluso puede reutilizar este patrón para buscar algo diferente, como licencias:

import { actions, licenses } from "./constants.js";

const mappings = new Map();

mappings.set(actions.MODIFY_FILE, [licenses.PAID]);
mappings.set(actions.VIEW_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.DELETE_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.CREATE_FILE, [licenses.PAID]);

function hasLicense(user, action) {
  if (mappings.has(action)) {
    return mappings.get(action).includes(user.license);
  }

  return false;
}

En lugar del rol de un usuario, afirmamos su propiedad de licencia: misma entrada, misma salida, contexto completamente diferente.

En mi equipo, necesitábamos verificar tanto los roles de usuario como las licencias, ya sea en conjunto o por separado. Cuando elegimos este patrón, creamos diferentes funciones para diferentes comprobaciones y las combinamos en un contenedor. Lo que terminamos usando fue una utilidad hasAccess:

function hasAccess(file, user, action) {
  return hasPermission(file, action) && hasLicense(user, action);
}

No es ideal pasar tres argumentos cada vez que llama a hasAccess, y es posible que encuentre una forma de evitarlo en su aplicación (como currying o estado global). En nuestra aplicación, usamos tiendas globales que contienen la información del usuario, por lo que podemos simplemente eliminar el segundo argumento y obtenerlo de una tienda.

También puede profundizar en términos de estructura de permisos. ¿Tiene diferentes tipos de archivos (o entidades, para ser más generales)? ¿Desea habilitar ciertos tipos de archivos según la licencia del usuario? Tomemos el ejemplo anterior y hagámoslo un poco más potente:

const mappings = new Map();

mappings.set(
  actions.EXPORT_FILE,
  new Map([
    [types.PDF, [licenses.FREE, licenses.PAID]],
    [types.DOCX, [licenses.PAID]],
    [types.XLSX, [licenses.PAID]],
    [types.PPTX, [licenses.PAID]]
  ])
);

Esto agrega un nivel completamente nuevo a nuestro verificador de permisos. Ahora, podemos tener diferentes tipos de entidades para una sola acción. Supongamos que desea proporcionar un exportador para sus archivos, pero desea que sus usuarios paguen por ese convertidor de Microsoft Office súper elegante que ha creado (¿y quién podría culparlo?). En lugar de proporcionar directamente una matriz, anidamos un segundo mapa dentro de la acción y pasamos todos los tipos de archivos que queremos cubrir. ¿Por qué usar un mapa? Por la misma razón que mencioné anteriormente: proporciona algunos métodos amigables como .has (). Sin embargo, siéntase libre de usar algo diferente.

Con el cambio reciente, nuestra función hasLicense ya no lo corta, por lo que es hora de actualizarlo un poco:

function hasLicense(user, file, action) {
  if (!user || !file) {
    return false;
  }

  if (mappings.has(action)) {
    const mapping = mappings.get(action);

    if (mapping.has(file.type)) {
      return mapping.get(file.type).includes(user.license);
    }
  }

  return false;
}

No sé si soy solo yo, pero ¿acaso no parece muy legible, a pesar de que la complejidad ha aumentado?

Pruebas

Si desea asegurarse de que su aplicación funcione como se espera, incluso después de la refactorización del código o la introducción de nuevas funciones, es mejor que tenga preparada una cobertura de prueba. En lo que respecta a probar los permisos de usuario, puede utilizar diferentes enfoques:

  • Cree pruebas instantáneas para asignaciones, acciones, tipos, etc. Esto se puede lograr fácilmente en Jest u otros ejecutores de prueba y garantiza que nada se deslice inesperadamente a través de la revisión del código. Sin embargo, puede resultar tedioso actualizar estas instantáneas si los permisos cambian todo el tiempo.
  • Agregue pruebas unitarias para hasLicense o hasPermission y afirme que la función está funcionando como se esperaba codificando algunos casos de prueba del mundo real. Las funciones de prueba unitaria son en su mayoría, si no siempre, una buena idea, ya que desea asegurarse de que se devuelva el valor correcto.
  • Además de garantizar que la lógica interna funcione, puede utilizar pruebas de instantáneas adicionales en combinación con sus constantes para cubrir todos los escenarios. Mi equipo usa algo similar a esto:
Object.values(actions).forEach((action) => {
  describe(action.toLowerCase(), function() {
    Object.values(licenses).forEach((license) => {
      it(license.toLowerCase(), function() {
        expect(hasLicense({ type: 'PDF' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'DOCX' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'XLSX' }, { license }, action)).toMatchSnapshot();
        expect(hasLicense({ type: 'PPTX' }, { license }, action)).toMatchSnapshot();
      });
    });
  });
});

Pero, de nuevo, hay muchas preferencias personales diferentes y formas de probarlo.

Conclusión

¡Y eso es! Espero que haya podido obtener algunas ideas o inspiración para su próximo proyecto y que este patrón pueda ser algo que desee alcanzar. Para recapitular algunas de sus ventajas:

  • No más necesidad de condiciones complicadas o lógica en su interfaz de usuario (componentes). Puede confiar en el valor de retorno de la función hasPermission y mostrar y ocultar elementos cómodamente en función de eso. Ser capaz de separar la lógica empresarial de su interfaz de usuario ayuda con una base de código más limpia y fácil de mantener.
  • Una única fuente de verdad para sus permisos. En lugar de revisar muchos archivos para averiguar qué puede o no ver un usuario, diríjase a las asignaciones de permisos y busque allí. Esto hace que extender y cambiar los permisos de usuario sea muy sencillo, ya que es posible que ni siquiera necesite tocar ninguna marca.
  • Muy comprobable. Ya sea que decida realizar pruebas instantáneas, pruebas de integración con otros componentes u otra cosa, es fácil escribir pruebas para los permisos centralizados.
  • Documentación. No es necesario que escriba su aplicación en TypeScript para beneficiarse de la finalización automática o la validación del código; el uso de constantes predefinidas para acciones, roles, licencias, etc. puede simplificar su vida y reducir los molestos errores tipográficos. Además, otros miembros del equipo pueden detectar fácilmente qué acciones, roles o lo que estén disponibles y dónde se están utilizando.

Suponga que desea ver una demostración completa de este patrón, diríjase a este CodeSandbox que juega con la idea usando React. Incluye diferentes comprobaciones de permisos e incluso algunas pruebas de cobertura.

¿Qué piensas? ¿Tiene un enfoque similar para estas cosas y cree que vale la pena el esfuerzo? Siempre estoy interesado en lo que se le ocurrió a otras personas, no dude en publicar cualquier comentario en la sección de comentarios. ¡Cuídate!