Yago Marinho
Volver a los artículos
  • Auth
  • Arquitectura
  • Python
  • Microservicios

Prohibido tocar la API de permisos: cómo saqué la autorización de la red

Cuando todas las aplicaciones consultan la misma API de permisos, su lentitud es la de todas. Separé el dato de la decisión en un motor de reglas en Python.

Foto de Yago MarinhoPor: Yago MarinhoÚltima actualización realizada el:
💡
Este es el primero de tres artículos sobre un motor de autorización que creé bajo un contrato privado, como desarrollador de una de las aplicaciones de un ecosistema más grande. Cuento el problema, la idea y las decisiones; el código original y los detalles del cliente quedan fuera, y los ejemplos se escribieron desde cero, en un dominio inventado.
Toda aplicación con usuarios necesita responder, en cada solicitud, una pregunta simple: ¿esta persona puede hacer esto? La forma más común de responderla es preguntarle a otro servicio. Y ahí una pregunta simple puede convertirse en el punto débil de todo un ecosistema.
Este artículo cuenta cómo resolví la autorización de una aplicación sin poder tocar el servicio que respondía esa pregunta para todas las demás, y por qué la solución terminó siendo un pequeño lenguaje de reglas en Python.

El problema que me asignaron

Trabajaba como desarrollador en una de las aplicaciones de un ecosistema con varios sistemas y con los datos repartidos en varias bases de datos. Los permisos vivían en una API central: antes de responder, cada aplicación le preguntaba si ese usuario podía hacer eso.
La aplicación en la que yo trabajaba necesitaba su propia autorización, con usuarios que tienen roles distintos en organizaciones distintas y rutas que exigen cosas distintas. La tarea llegó con una condición clara: resolverlo sin tocar la API de permisos. Esa API atendía a las demás aplicaciones, y cambiar su comportamiento no estaba en discusión.
Una restricción así parece solo un límite. En este caso, terminó guiando la solución.

Por qué una API central de permisos se vuelve un punto único de falla

Una API central de permisos es la forma más natural de empezar: un solo lugar para registrar quién puede hacer qué, y un solo lugar para consultar. No hay nada de malo en esa elección al principio. El costo aparece cuando crecen la cantidad de aplicaciones y el volumen de solicitudes, y llega de tres formas.

La disponibilidad se multiplica

Si cada respuesta depende de que dos servicios estén arriba, la disponibilidad del conjunto es el producto de las disponibilidades de cada uno. Con números ilustrativos: una aplicación con 99.9% de disponibilidad que consulta una API de permisos con 99.5% queda en 99.9% × 99.5% ≈ 99.4%. Sola, estaría caída unas 9 horas al año. Dependiendo de la otra, supera las 52.
Y la cuenta empeora con cada dependencia que entra en la misma cadena.

La latencia se suma

Cada solicitud gana un viaje de ida y vuelta por la red antes de empezar a hacer lo que importa. Una pantalla que dispara diez solicitudes hace diez consultas a la misma API, y esa API recibe la suma del tráfico de todas las aplicaciones.

La falla se propaga

Cuando la API de permisos se pone lenta, las aplicaciones no se caen de inmediato: esperan. Las conexiones quedan retenidas, los tiempos de espera se agotan y los reintentos aumentan la carga justo sobre quien ya está sobrecargado. La lentitud de un servicio se vuelve la lentitud de todos.

Separar el dato de la decisión

Lo que me destrabó fue darme cuenta de que "autorizar" junta dos cosas distintas:
  • el dato: quién tiene qué rol, en qué alcance;
  • la decisión: ¿esta concesión cubre esta solicitud?
El dato cambia poco: alguien gana un rol, pierde otro, cambia de organización. La decisión ocurre en cada solicitud. Una API central consultada en cada solicitud pone del otro lado de la red justamente la parte que ocurre todo el tiempo.
Hay un vocabulario antiguo para estas piezas, que viene de XACML, un estándar de control de acceso:
  • el PEP (policy enforcement point) es quien bloquea la solicitud;
  • el PDP (policy decision point) es quien decide;
  • el PIP (policy information point) es de donde vienen los hechos para la decisión.
En el diseño que encontré, el PEP estaba en cada aplicación, y el PDP y el PIP estaban juntos, del otro lado de la red. Mi salida fue traer los dos dentro de la aplicación, en dos movimientos.

1. Los datos cerca

El modelo de permisos pasó a vivir en la base de datos de la propia aplicación: los usuarios, los roles y las concesiones. Una concesión es un rol en un alcance. La misma persona puede ser administradora en una organización y usuaria común en otra: son dos concesiones, y una no le presta nada a la otra.

2. La decisión dentro del proceso

El PDP se convirtió en una biblioteca, llamada por la propia ruta. Decidir dejó de ser un viaje por la red: es una función que compara datos que ya están en el servicio.
La API central siguió exactamente como estaba, atendiendo a las demás aplicaciones. La restricción se respetó, y la aplicación en la que yo trabajaba dejó de depender de ella para responder.

Por qué un lenguaje de reglas, y no ifs

La forma más directa de autorizar dentro de la aplicación sería esparcir condiciones por las rutas:
if usuario.rol == "admin" or ( usuario.rol == "editor" and documento.organizacion in usuario.organizaciones ): ...
Funciona en la primera ruta. En la vigésima, cada ruta tiene su propia interpretación de lo que es ser admin, saber quién puede hacer qué exige leer todo el código, y cambiar una regla significa buscar todos los lugares donde se escribió.
Yo quería lo contrario: que cada ruta declarara lo que exige, en una línea, y que un motor solo hiciera la comparación. Algo así:
@requiere("usuario", "documento", "leer") async def ver_documento(solicitud): ...
Se lee: alguien con privilegio de usuario, o mayor, con permiso para leer documentos. El decorador arma el requisito con la organización de la solicitud, busca las concesiones del usuario en la base de datos de la aplicación y le pregunta al motor. La ruta no sabe cómo se toma la decisión; solo dice lo que necesita.
Eso es lo que llamo DSL aquí: un lenguaje pequeño, del dominio de la autorización, en el que la regla es un dato. Y un dato se puede listar, revisar y probar, e incluso puede salir del código e irse a la base de datos.

Cómo decide el motor

El motor no sabe qué es un documento, una organización o un admin. Compara dos datos, el requisito de la ruta y cada concesión del usuario, campo por campo, según un esquema que dice qué comparador usar en cada campo:
NIVELES = {"usuario": 1, "admin": 2, "root": 3} def igual(exigido, concedido): return exigido is not None and exigido == concedido def igual_o_comodin(exigido, concedido): if exigido is None or concedido is None: return False # un campo ausente nunca autoriza return concedido == "*" or exigido == concedido def nivel_minimo(exigido, concedido): if exigido not in NIVELES or concedido not in NIVELES: return False return NIVELES[concedido] >= NIVELES[exigido] COMPARADORES = { "igual": igual, "igual_o_comodin": igual_o_comodin, "nivel_minimo": nivel_minimo, } ESQUEMA = { "privilegio": "nivel_minimo", "recurso": "igual_o_comodin", "accion": "igual_o_comodin", "organizacion": "igual", } def autorizar( requisito, concesiones, esquema=ESQUEMA, comparadores=COMPARADORES ): return [ concesion for concesion in concesiones if all( comparadores[nombre](requisito.get(campo), concesion.get(campo)) for campo, nombre in esquema.items() ) ]
Hay tres ideas en ese fragmento:
  • El privilegio es un nivel, no un nombre. Vale "a partir de": un admin cumple un requisito de usuario, y no al revés. Un nivel desconocido no cumple nada.
  • El comodín es explícito y solo vale del lado de la concesión. Un * en la concesión cubre cualquier recurso; un * en el requisito no abre nada.
  • Un campo ausente nunca autoriza. Parece un detalle, y no lo es. Vuelvo a él en el tercer artículo.
Con un requisito y dos concesiones, el motor devuelve solo la que cubre la solicitud:
requisito = { "privilegio": "usuario", "recurso": "documento", "accion": "leer", "organizacion": "org-a", } concesiones = [ {"privilegio": "usuario", "recurso": "documento", "accion": "leer", "organizacion": "org-a"}, {"privilegio": "admin", "recurso": "*", "accion": "*", "organizacion": "org-b"}, ] autorizar(requisito, concesiones) # [{"privilegio": "usuario", "recurso": "documento", "accion": "leer", "organizacion": "org-a"}]
La administradora de org-b, aun con comodín en todo, no alcanza un documento de org-a. El aislamiento entre organizaciones es solo un campo más que se compara, y no tiene excepción.

Una dimensión nueva sin tocar el motor

La parte que más me gusta de este diseño es lo que pasa cuando aparece una regla nueva. Digamos que algunas concesiones empiezan a valer solo para ciertas regiones. No hace falta tocar autorizar: basta con un comparador nuevo y un campo más en el esquema.
def pertenece(exigido, concedido): if exigido is None or concedido is None: return False return exigido in concedido COMPARADORES["pertenece"] = pertenece ESQUEMA_CON_REGION = {**ESQUEMA, "region": "pertenece"}
Una concesión con "region": ["norte", "noreste"] cubre una solicitud del norte y rechaza una del sur, y las rutas que usan el esquema anterior siguen iguales. Es el principio abierto/cerrado en el sentido más literal: el motor queda cerrado a la modificación y abierto a la extensión.

La respuesta es una lista, no un sí

Observa que autorizar no devuelve True o False. Devuelve las concesiones que autorizaron. La ruta sabe por cuál concesión pasó la solicitud, y en qué alcance.
Eso parece un detalle de implementación, pero cambia el papel de la autorización: deja de responder solo "¿puede?" y pasa a responder "¿puede, sobre qué?". Es el tema del próximo artículo.

Probar sin base de datos y sin red

Como el motor es una función pura, probarlo es escribir datos y verificar el resultado. No hay base de datos, ni red, ni mocks, y cada regla del motor tiene una prueba que la describe:
def test_admin_de_una_organizacion_no_alcanza_otra(): concesiones = [ {"privilegio": "admin", "recurso": "*", "accion": "*", "organizacion": "org-b"}, ] requisito = { "privilegio": "usuario", "recurso": "documento", "accion": "leer", "organizacion": "org-a", } assert autorizar(requisito, concesiones) == [] def test_privilegio_mayor_cubre_el_menor_y_no_al_reves(): pide_usuario = { "privilegio": "usuario", "recurso": "documento", "accion": "leer", "organizacion": "org-a", } pide_admin = {**pide_usuario, "privilegio": "admin"} admin = [{**pide_usuario, "privilegio": "admin"}] usuario = [pide_usuario] assert autorizar(pide_usuario, admin) == admin assert autorizar(pide_admin, usuario) == []
El decorador, que hace la parte con efectos (leer el usuario de la solicitud y buscar las concesiones en la base de datos), recibe esas fuentes como parámetros. En las pruebas, se convierten en funciones falsas que devuelven datos fijos, y todo el camino de la ruta se prueba sin levantar nada.

Lo que quedó fuera de mi alcance

La solución valió para la aplicación en la que yo trabajaba. La API central siguió atendiendo a las demás, como pedía la tarea, y la arquitectura del ecosistema no era una decisión mía.
Si lo hubiera sido, habría propuesto el mismo principio para el resto: la API central como el lugar donde se registran y administran las concesiones, y el motor como una biblioteca en cada servicio, que decide con una copia local de las concesiones que le interesan. La gestión queda en un solo lugar; la decisión, cerca de quien la necesita.
Esa elección tiene un precio, y es real: la aplicación pasó a guardar sus propios usuarios y concesiones. Dos fuentes de verdad exigen cuidado, y de eso trata el tercer artículo.

Lo que me llevo de este proyecto

  • Separa el dato de la decisión. El dato puede vivir lejos y cambiar poco; la decisión ocurre en cada solicitud y necesita estar cerca.
  • Lee la restricción como un requisito. "No toques la API" parecía un obstáculo y terminó guiando el diseño.
  • Una regla como dato envejece mejor que una regla dispersa. Una línea por ruta se lee, se prueba y se cambia en un solo lugar.
  • El motor no debe conocer el dominio. Cuanto menos sabe, más reglas caben en él sin cambiarlo.
En el próximo artículo cuento lo que entregó este motor, sobre todo lo que cambia cuando la autorización empieza a decir sobre qué datos puede actuar una persona.
Foto de Yago Marinho
Yago MarinhoIngeniero de Software Sénior. Construyo productos con IA, del diseño al despliegue.
Ver portafolio ↗