wandres.dev
PATRÓN: AUTH · sesiones y permisos

Permisos: guardas en las aristas, no condicionales sueltos

La autorización escrita como comparaciones de rol repartidas por cuarenta componentes es la deuda técnica más silenciosa que existe, porque cada copia envejece por separado y ninguna aparece en el diagrama. Esta lección traslada el permiso a donde pertenece, la arista de la transición, con guardas nombradas y parametrizadas que se componen mediante conjunción, disyunción y negación, y muestra cómo la interfaz deja de deducir para preguntar: el método que interroga a la máquina sobre si un evento sería aceptado convierte la política y la afordancia visual en la misma fuente de verdad.

⏱ 19 min

Casi todas las aplicaciones empiezan su autorización con una línea razonable en un componente razonable, algo del estilo de comprobar si el rol del usuario es administrador antes de pintar un botón. El problema no es esa línea sino su descendencia: seis meses después existen cuarenta variantes suyas repartidas por la interfaz, tres de ellas ligeramente distintas, ninguna probada, y nadie en el equipo puede responder a la pregunta de quién puede aprobar un documento sin abrir el buscador. La autorización se ha vuelto invisible precisamente por estar en todas partes. Modelarla como guardas sobre las transiciones invierte esa dispersión: la regla se escribe una vez, con nombre, en el mismo sitio donde vive el resto del comportamiento, y todo lo demás la consulta en lugar de reproducirla.

🎯 Al terminar esta lección sabrás
  • Reubicar la comprobación de permiso desde el componente hacia la arista de la transición.
  • Escribir guardas nombradas y parametrizadas por permiso en lugar de comparar roles literales.
  • Componer condiciones con conjunción, disyunción, negación y pertenencia a un estado.
  • Derivar la interfaz preguntando a la máquina si un evento sería aceptado, sin duplicar la regla.

La regla vive en la arista

Un permiso responde siempre a la misma forma de pregunta: si este usuario intenta esta acción en esta situación, ¿debe ocurrir algo? Esa forma es literalmente la de una guarda, un predicado que decide si una transición se toma. Cuando la comprobación está en el componente, la máquina acepta el evento y confía en que nadie se lo mande; cuando está en la arista, mandarlo es inofensivo porque no produce efecto. La diferencia práctica aparece el día que un evento llega por un camino nuevo —un atajo de teclado, una acción por lotes, una llamada desde otra máquina— y descubre que la única defensa estaba dibujada en un botón.

import { setup, assign, and, or, not } from 'xstate'

export const documento = setup({
  types: {
    context: {} as { permisos: ReadonlySet<string>; autor: string; yo: string },
    events: {} as
      | { type: 'EDITAR' } | { type: 'ENVIAR_A_REVISION' }
      | { type: 'APROBAR' } | { type: 'PUBLICAR' }
      | { type: 'PERMISOS_CAMBIADOS'; permisos: string[] },
  },
  guards: {
    tienePermiso: ({ context }, params: { clave: string }) => context.permisos.has(params.clave),
    esAutor: ({ context }) => context.autor === context.yo,
  },
}).createMachine({
  id: 'documento',
  initial: 'borrador',
  context: { permisos: new Set(), autor: '', yo: '' },
  on: {
    PERMISOS_CAMBIADOS: { actions: assign({ permisos: ({ event }) => new Set(event.permisos) }) },
  },
  states: {
    borrador: {
      on: {
        EDITAR: {
          guard: or(['esAutor', { type: 'tienePermiso', params: { clave: 'doc.editar.ajeno' } }]),
        },
        ENVIAR_A_REVISION: { target: 'enRevision', guard: 'esAutor' },
      },
    },
    enRevision: {
      on: {
        APROBAR: {
          target: 'aprobado',
          guard: and([{ type: 'tienePermiso', params: { clave: 'doc.aprobar' } }, not('esAutor')]),
        },
        EDITAR: { target: 'borrador', guard: { type: 'tienePermiso', params: { clave: 'doc.devolver' } } },
      },
    },
    aprobado: {
      on: {
        PUBLICAR: { target: 'publicado', guard: { type: 'tienePermiso', params: { clave: 'doc.publicar' } } },
      },
    },
    publicado: { type: 'final' },
  },
})

Hay tres decisiones de diseño en ese fragmento que conviene señalar. La primera es que las guardas comprueban permisos y no roles: el contexto guarda un conjunto de capacidades y el rol se queda fuera de la máquina, donde le corresponde, como una abreviatura que el servidor expande antes de entregar la sesión. Añadir un rol nuevo deja de tocar la máquina, que es exactamente la propiedad que se busca. La segunda es que la condición de aprobación combina tener permiso con no ser el autor, una regla de negocio real que ningún sistema de roles expresa por sí solo y que aquí queda escrita en una sola arista. La tercera es que los permisos se actualizan por evento en el nivel raíz, porque las capacidades cambian a mitad de sesión y una máquina que las lea una vez al arrancar se queda desfasada sin enterarse.

ℹ️
Roles fuera, capacidades dentro

El rol es una compresión de un conjunto de permisos, útil para administrar personas y pésima para escribir reglas. Cuando una guarda pregunta si alguien es administrador, la regla queda atada a una decisión organizativa que cambiará; cuando pregunta si tiene la capacidad de publicar, la regla sobrevive a cualquier reorganización de roles porque describe la acción y no al actor. Deja la traducción de roles a capacidades donde hay autoridad para hacerla, que es el servidor, y haz que la máquina razone siempre en el nivel de la acción.

Componer en lugar de anidar

Las condiciones reales rara vez son atómicas, y ahí es donde el condicional disperso degenera más deprisa: una comparación se convierte en dos unidas por una conjunción, luego aparece una excepción, y en tres iteraciones nadie sabe leer la expresión. Los combinadores de guardas mantienen la legibilidad porque cada pieza conserva su nombre y la composición se lee como la frase del dominio que representa.

🔗

Conjunción

Exigir varias condiciones a la vez, como tener la capacidad y además no ser quien escribió el documento.

🔀

Disyunción

Admitir caminos alternativos: el autor edita lo suyo, o alguien con capacidad sobre documentos ajenos.

🚫

Negación

Expresar prohibiciones explícitas sin inventar predicados espejo que luego se contradicen entre sí.

🧭

Pertenencia a un estado

Condicionar por dónde está otra región de la máquina, útil cuando la sesión y el documento avanzan en paralelo.

La última tarjeta apunta a un patrón que resuelve el problema más incómodo de este nivel: la autorización cruza dos dominios que evolucionan por separado, la sesión y el recurso. Modelarlos como regiones paralelas permite que una guarda del documento consulte en qué estado está la sesión sin que ninguna de las dos regiones dependa del ciclo de vida de la otra.

import { stateIn } from 'xstate'

PUBLICAR: {
  target: 'publicado',
  guard: and([
    stateIn({ sesion: 'autenticado' }),
    { type: 'tienePermiso', params: { clave: 'doc.publicar' } },
  ]),
},
stateDiagram-v2
[*] --> borrador
borrador --> enRevision: ENVIAR_A_REVISION si es el autor
borrador --> borrador: EDITAR si es autor o tiene permiso sobre ajenos
enRevision --> aprobado: APROBAR si tiene permiso y no es el autor
enRevision --> borrador: EDITAR si tiene permiso de devolucion
aprobado --> publicado: PUBLICAR si tiene permiso y la sesion sigue viva
publicado --> [*]

La interfaz pregunta en vez de deducir

Con las reglas en las aristas queda un cabo suelto que decide si todo el ejercicio sirve de algo: cómo sabe el botón si debe estar disponible. Reimplementar la condición en el componente devolvería la duplicación por la puerta de atrás, y esta vez con el agravante de que las dos copias parecerían coherentes hasta que dejaran de serlo. La respuesta es no deducir nada y preguntar directamente al actor si el evento produciría una transición.

const instantanea = actorDocumento.getSnapshot()

const acciones = [
  { evento: { type: 'ENVIAR_A_REVISION' } as const, etiqueta: 'Enviar a revision' },
  { evento: { type: 'APROBAR' } as const, etiqueta: 'Aprobar' },
  { evento: { type: 'PUBLICAR' } as const, etiqueta: 'Publicar' },
].filter((a) => instantanea.can(a.evento))

El método que interroga la instantánea evalúa el estado actual, las transiciones declaradas y las guardas asociadas, y responde si el evento sería aceptado. La consecuencia es que la política queda escrita una vez y la interfaz se convierte en su lector: añadir una capacidad, cambiar una condición o introducir un estado nuevo actualiza los botones sin tocar un solo componente. Es la misma relación que hay entre una gramática y un corrector, y explica por qué esta forma de autorizar envejece bien mientras la otra se pudre.

Enfoque Dónde vive la regla Qué ocurre al cambiarla Qué se puede probar
condicional en el componente repartida por la interfaz hay que encontrar todas las copias solo renderizando
función auxiliar compartida en un módulo aparte un sitio, pero invisible en el grafo la función, no el flujo
guarda en la transición en la arista del grafo un sitio, y el diagrama lo refleja el flujo completo sin interfaz
guarda más consulta desde la vista una sola definición | dos lectores nada que sincronizar política y afordancia a la vez
💡
Inhabilitar y ocultar no son lo mismo

Que la máquina diga que un evento no sería aceptado no determina cómo mostrarlo. Inhabilitar comunica que la acción existe y hoy no está disponible, lo que es correcto cuando el usuario podría obtener el permiso o cuando la acción se desbloquea más adelante en el flujo. Ocultar es preferible cuando la existencia misma de la acción revela información que no le corresponde. Decide esa política por capacidad y de forma consciente, porque un botón inhabilitado es también una filtración cuando su etiqueta describe algo que el usuario no debería saber que existe.

Afordancia no es autorización

Todo lo anterior describe lo que la aplicación ofrece, y ofrecer no es permitir. Un cliente puede ser modificado, una petición puede fabricarse a mano y una guarda del navegador no defiende nada frente a quien no usa el navegador. La máquina de permisos existe para que la interfaz sea coherente, comprensible y difícil de usar mal, no para proteger el recurso; la protección se verifica en el servidor, en cada petición, con la misma regla expresada en su propio dominio.

Esa duplicación entre cliente y servidor es legítima y hay que gestionarla con honestidad. Lo peligroso no es que la regla exista en dos sitios, sino que diverja sin que nadie lo note: la interfaz ofrece una acción que el servidor rechazará, o la esconde aunque estuviera permitida. La disciplina que mejor funciona es que el servidor sea la única fuente de las capacidades y las entregue con la sesión, de modo que el cliente no decida quién puede qué sino solo cómo presentar lo que el servidor ya decidió.

⚠️
Las capacidades caducan antes que la sesión

Un permiso retirado a mitad de la tarde no llega solo al cliente: la aplicación seguirá ofreciendo la acción hasta que alguien le informe. Trata el conjunto de capacidades como dato con frescura, refréscalo cuando se renueve la credencial y admite un evento que lo actualice desde una notificación del servidor. Y cuando una petición sea rechazada por permisos, no la confundas con una credencial caducada: no hay refresco que arregle una capacidad retirada, y tratarla como tal produce el bucle de renovaciones inútiles que ya vimos.

Autorizar es decidir qué transiciones existen, no qué personas mandan

La forma habitual de pensar la autorización pone al sujeto en el centro: hay usuarios, tienen roles, y los roles conceden poderes. Ese encuadre es cómodo para administrar y engañoso para diseñar, porque describe a las personas cuando lo que hay que describir son los cambios de estado legales. Un permiso no es una propiedad de alguien, es una condición sobre una arista: dice bajo qué circunstancias este sistema puede pasar de aquí a allá, y entre esas circunstancias figura quién lo intenta, pero también dónde estaba el sistema, qué relación guarda el actor con el objeto y qué otras regiones están activas. En cuanto lo miras así, desaparecen los enredos clásicos, porque preguntas como si el autor puede aprobar su propio documento dejan de ser un problema de modelo de roles —donde no tienen solución limpia y se acaban parcheando con roles artificiales— y pasan a ser una conjunción de dos predicados en la única arista donde importan. Y aparece además una consecuencia estructural: al vivir la regla en el grafo, la política se vuelve inspeccionable, dibujable y comprobable sin interfaz, de modo que la pregunta de quién puede aprobar deja de responderse con una búsqueda de texto y se responde mirando el diagrama. Ese es el desplazamiento que ordena todo el nivel y cierra el círculo que abrimos con la sesión: primero dejamos de representar la identidad como un booleano y la modelamos como una trayectoria; ahora dejamos de representar el poder como un atributo del sujeto y lo modelamos como una condición del movimiento. En ambos casos el patrón es idéntico y es la tesis de este track entero: lo que parecía una propiedad de las cosas era, mirado con cuidado, una regla sobre sus cambios.

⚔️ Reúne tu política dispersa
  1. Busca en tu código todas las comparaciones de rol y cuenta cuántas versiones distintas existen de la misma regla.
  2. Traduce cada rol a un conjunto de capacidades y traslada la traducción al servidor, dejando la máquina razonando solo en capacidades.
  3. Mueve al menos tres comprobaciones desde componentes hasta guardas en la arista correspondiente y borra las originales.
  4. Expresa con conjunción y negación una regla que hoy tengas duplicada, como aprobar sin ser el autor, y compruébala con un test del flujo.
  5. Sustituye la lógica de habilitación de tus botones por la consulta a la instantánea y verifica que ya no queda ninguna condición repetida en la vista.
  6. Retira un permiso desde el servidor con la sesión abierta y comprueba cuánto tarda tu interfaz en dejar de ofrecer la acción.