Manejo de permisos de usuario en JavaScript

Has 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 quieras invitar a personas con acceso de solo lectura o personas que puedan editar pero no eliminar tus archivos. ¿Cómo maneja esa lógica en el front-end sin saturar su código con demasiadas condiciones y comprobaciones complicadas?
En este artículo, repasaremos una implementación de ejemplo sobre cómo manejar este tipo de situaciones de una manera elegante y limpia. Tómelo con cautela: sus necesidades pueden diferir, pero espero que pueda obtener algunas ideas.
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). Para esta demostración, usemos los siguientes roles:
- 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 que existen, 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 renderizado del lado del servidor, aún puede aplicar el mismo patrón que vamos a ver.
La clave es devolver la función (o permiso, si prefiere ese nombre) del usuario que ha iniciado sesión actualmente al recuperar algunos datos.
{ id: 1, title: "My First Document", authorId: 742, accessLevel: "ADMIN", content: {...}}
Aquí, recuperamos un documento con algunas propiedades, incluida una propiedad llamada accessLevel
para el rol del usuario. Así es como sabemos qué puede o no hacer el usuario que ha iniciado sesión. Nuestro próximo trabajo es agregar algo de lógica en la interfaz para garantizar que los invitados no vean cosas que no deberían ver, y viceversa.
Idealmente, no confíe únicamente en la interfaz para verificar los permisos. Alguien con experiencia en tecnologías web aún podría enviar una solicitud sin interfaz de usuario al servidor con la intención de manipular datos, por lo que su backend también debería verificar las cosas.
Por cierto, este patrón es independiente del marco; no importa si trabajas con React, Vue o incluso algún JavaScript Vanilla salvaje.
Definiendo constantes
El primer paso (opcional, pero muy recomendado) es crear algunas constantes. Serán objetos simples que contendrán 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 tus acciones y roles tiene algunas ventajas:
- Una única fuente de verdad. En lugar de revisar todo el código base, 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 una función 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, lo más probable es que ESLint o alguna otra herramienta le grite hasta que lo solucione.
- 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; impórtalos y úsalos así:
import { actions } from "./constants.js";console.log(actions.CREATE_FILE);
Definición de permisos
Pasemos 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 que más me gusta es la siguiente. Creemos un nuevo archivo, llamémoslo permissions.js
y coloquemos algo de 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 JavaScript , llamado
mappings
. 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 lo pareció), pero con el tiempo aprendí a apreciarlo. Los beneficios son evidentes, especialmente en aplicaciones más grandes con toneladas de acciones y roles:
- Una vez más, sólo una fuente de verdad. ¿Necesita saber qué roles se requieren para editar un archivo? No hay problema, dirígete
permissions.js
y busca 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 rol a la
DELETE_FILE
entrada 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 utilizar 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.
Comprobación de permisos en la interfaz de usuario
Definimos todas nuestras acciones y roles y creamos un mapa que explica quién puede hacer qué. Es hora de implementar una función que podamos usar en nuestra interfaz de usuario para verificar esos roles.
Al crear un comportamiento 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 libutton type="button"Refresh/buttonli libutton type="button"Rename/buttonli libutton type="button"Duplicate/buttonli libutton type="button"Delete/buttonli /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 todo menos “Eliminar”. Me imagino alguna API como esta:
hasPermission(file, actions.DELETE_FILE);
El primer argumento es el archivo en sí, obtenido por nuestra API REST. Debe contener la accessLevel
propiedad anterior, 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 debería devolver un valor booleano true
si el usuario que ha iniciado sesión actualmente tiene permisos para esa acción, o false
si no.
import hasPermission from "./permissions.js";import { actions } from "./constants.js";function Dropdown() { return ( ul {hasPermission(file, actions.VIEW_FILE) ( libutton type="button"Refresh/button/li )} {hasPermission(file, actions.MODIFY_FILE) ( libutton type="button"Rename/button/li )} {hasPermission(file, actions.CREATE_FILE) ( libutton type="button"Duplicate/button/li )} {hasPermission(file, actions.DELETE_FILE) ( libutton 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 el curry), pero para mí, esto ha hecho un trabajo bastante bueno, incluso en aplicaciones con permisos súper complejos. Claro, el JSX parece más desordenado, pero 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 e intuitivos de entender.
Por si todavía no estás convencido, veamos cómo quedaría sin hasPermission
ayuda:
return ( ul {['ADMIN', 'EDITOR', 'GUEST'].includes(file.accessLevel) ( libutton type="button"Refresh/button/li )} {['ADMIN', 'EDITOR'].includes(file.accessLevel) ( libutton type="button"Rename/button/li )} {['ADMIN', 'EDITOR'].includes(file.accessLevel) ( libutton type="button"Duplicate/button/li )} {file.accessLevel == "ADMIN" ( libutton type="button"Delete/button/li )} /ul);
Se podría decir que esto no parece tan malo, pero piense en lo que sucede si se agrega más lógica, como comprobaciones de licencia 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 de todos modos todos pueden ver el botón “Actualizar”? Me gusta tenerlo ahí porque nunca se sabe lo que podría cambiar en el futuro. Es posible que se introduzca una nueva función que tal vez ni siquiera vea el botón. En ese caso, solo tiene que actualizar permissions.js
y dejar el componente en paz, lo que resulta en un compromiso de Git más limpio y menos posibilidades de cometer errores.
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 };
Puedes poner el código anterior en un archivo separado o incluso dentro de permissions.js
. Personalmente los mantengo juntos en un archivo pero, oye, no te estoy diciendo cómo vivir tu vida.
Digamos lo que está pasando aquí:
- Definimos una nueva función,
hasPermission
utilizando la misma firma API que decidimos anteriormente. Toma el archivo (que viene del backend) y la acción que queremos realizar. - Como medida de seguridad, si, por alguna razón, el archivo contiene
null
o no unaaccessLevel
propiedad, devolvemosfalse
. Es mejor tener mucho cuidado de no exponer información “secreta” al usuario causada por un fallo o algún error en el código. - Llegando al núcleo, comprobamos si
mappings
contiene la acción que buscamos. Si es así, podemos obtener su valor de forma segura (recuerde, es una serie de roles) y verificar si nuestro usuario actualmente conectado tiene el rol requerido para esa acción. Esto regresatrue
ofalse
. - Finalmente, si
mappings
no contiene la acción que buscamos (puede ser un error en el código o un fallo técnico nuevamente), volvemosfalse
a estar extra seguros. - En las dos últimas líneas, no solo exportamos la
hasPermission
función sino que también reexportamos nuestras constantes para comodidad 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 mostrado es bastante simple con fines de demostración. Aún así, puedes tomarlo como base para tu 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 puedes reutilizar este patrón para verificar 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 license
propiedad: misma entrada, misma salida, contexto completamente diferente.
En mi equipo, necesitábamos verificar tanto los roles de usuario como las licencias, ya sea juntos o por separado. Cuando elegimos este patrón, creamos diferentes funciones para diferentes cheques y las combinamos en un contenedor. Lo que terminamos usando fue una hasAccess
utilidad:
function hasAccess(file, user, action) { return hasPermission(file, action) hasLicense(user, action);}
No es ideal pasar tres argumentos cada vez que llamas hasAccess
y es posible que encuentres una manera de evitarlo en tu aplicación (como curry o estado global). En nuestra aplicación, utilizamos tiendas globales que contienen la información del usuario, por lo que simplemente podemos 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)? ¿Quiere 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 quiere que sus usuarios paguen por ese sofisticado conversor de Microsoft Office 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é utilizar un mapa?, te preguntarás. Por la misma razón que mencioné antes: proporciona algunos métodos amigables como .has()
. Sin embargo, siéntete libre de usar algo diferente.
Con el cambio reciente, nuestra hasLicense
función ya no es suficiente, por lo que es hora de actualizarla ligeramente:
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 ¿no parece todavía súper 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 alguna cobertura de prueba. En lo que respecta a probar los permisos de los usuarios, 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 pruebas y garantiza que nada se escape inesperadamente durante 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
ohasPermission
y afirme que la función funciona 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 instantáneas adicionales en combinación con sus constantes para cubrir cada escenario. 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 nuevamente, hay muchas preferencias personales diferentes y formas de probarlo.
Conclusión
¡Y eso es! Espero que hayas podido obtener algunas ideas o inspiración para tu próximo proyecto y que este patrón sea algo que quieras alcanzar. Para resumir algunas de sus ventajas:
- Ya no se necesitan condiciones o lógica complicadas en su interfaz de usuario (componentes). Puede confiar en el valor
hasPermission
de la funciónreturn
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 a tener un código base más limpio y fácil de mantener. - Una única fuente de verdad para sus permisos. En lugar de revisar muchos archivos para descubrir qué puede o no ver un usuario, diríjase a las asignaciones de permisos y mire allí. Esto hace que ampliar y cambiar los permisos de usuario sea muy sencillo, ya que es posible que ni siquiera necesites tocar ninguna marca.
- Muy comprobable. Ya sea que decida realizar pruebas instantáneas, pruebas de integración con otros componentes u otra cosa, es sencillo escribir pruebas para los permisos centralizados.
- Documentación. No necesita escribir su aplicación en TypeScript para beneficiarse del autocompletado o la validación del código; El uso de constantes predefinidas para acciones, roles, licencias y demás 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 sea que esté disponible y dónde se utilizan.
Supongamos que desea ver una demostración completa de este patrón, diríjase a este CodeSandbox que juega con la idea de usar React. Incluye diferentes comprobaciones de permisos e incluso alguna cobertura de prueba.
¿Qué opinas? ¿Tiene un enfoque similar para este tipo de cosas y cree que vale la pena el esfuerzo? Siempre estoy interesado en lo que se les ocurrió a otras personas, no dudes en publicar cualquier comentario en la sección de comentarios. ¡Cuidarse!
Deja un comentario