- Auth
- Arquitectura
- Python
- Seguridad
Implementando autorización en un sistema legado: desafíos de quien ejecuta
Enfrenté varias bases de datos, una API que no podía tocar y los riesgos de un motor genérico — y aprendí a entregar bien dentro de límites que no elegí.
Este es el tercer y último artículo de la serie sobre un motor de autorización que creé bajo un contrato privado. El primero cuenta el problema y la idea, y el segundo, lo que el motor entregó; este cuenta los desafíos. El código original y los detalles del cliente quedan fuera, y los ejemplos se escribieron desde cero, en un dominio inventado.
En el OWASP Top 10, la lista de las fallas más críticas en aplicaciones web, la pérdida de control de acceso (Broken Access Control) ocupa el primer lugar desde la edición de 2021 — y sigue ahí en la de 2025. Es decir: de todas las formas en que una aplicación puede fallar, la más común es que alguien consiga hacer lo que no debería.
Ese dato dice mucho sobre la autorización, pero no lo dice todo. Además de ser difícil por naturaleza, la autorización rara vez se construye desde cero, en un proyecto limpio. La mayoría de las veces nace dentro de un sistema que ya existe, con decisiones tomadas antes de que llegáramos y con límites que no elegimos nosotros.
Ese fue exactamente mi caso. En los dos artículos anteriores, conté cómo creé un motor de autorización para una aplicación sin poder tocar la API central de permisos, y lo que entregó. Ahora quiero hablar de la parte que suele quedar fuera de los artículos: los desafíos — los que enfrenté durante la entrega y los que aprendí a ver cuando miré el motor con más calma.
El escenario: entregar dentro de límitesDesafíos y soluciones encontradas1. Dos fuentes de verdad2. Dónde vive la regla3. El costo de cada solicitud y la cachéLos riesgos de un motor genérico#Case 1: el campo ausente que autoriza#Case 2: los nombres que se vuelven iguales#Case 3: “alguna” en lugar de “todas”#Case 4: el error de configuración que solo aparece en la solicitud#Case 5: la prueba que no distingue “negó” de “se rompió”Influir sin decidirRoadmap y próximos pasosConclusión
El escenario: entregar dentro de límites
Antes de los desafíos, vale recordar el escenario. Trabajaba como desarrollador en una de las aplicaciones de un ecosistema con varios sistemas, datos repartidos en varias bases de datos y una API central de permisos que todas las aplicaciones consultaban. La arquitectura de ese ecosistema no era una decisión mía, y la tarea que recibí llegó con una condición clara: resolver la autorización de la aplicación sin tocar la API central.
Esto no es una crítica a quien decidió. Cada decisión de ese ecosistema se tomó en un momento, con las restricciones de ese momento — y así es en prácticamente todo sistema con algunos años de vida. Para mí, el punto es otro: ¿cómo entregar algo bueno cuando buena parte del terreno ya está construida?
Vamos a los desafíos.
Desafíos y soluciones encontradas
1. Dos fuentes de verdad
Desafío: al llevar las concesiones a la base de datos de la aplicación, esta pasó a tener sus propios usuarios y concesiones, mientras las demás aplicaciones seguían usando la API central. En la práctica, pasaron a existir dos fuentes de verdad sobre quién puede hacer qué.
Solución: asumirlo de forma explícita, en lugar de esconderlo. El modelo de permisos valía solo para el alcance de esa aplicación: ella era dueña de sus concesiones, y la API central seguía siendo dueña de las concesiones de las demás. No había sincronización entre las dos, y fue una elección consciente.
Claro que esa elección tiene un precio. Quien administra los accesos pasa a gestionar personas en dos lugares, y una revocación hecha en la API central no llega sola a la aplicación. Si el alcance creciera, el camino que yo propondría es el que comenté en el primer artículo: la API central como el lugar donde se registran las concesiones, y cada servicio con una copia local de las concesiones que le interesan, actualizada por eventos. La gestión queda en un solo lugar; la decisión, cerca de quien la necesita.
2. Dónde vive la regla
Desafío: una regla de autorización tiene que vivir en algún lugar, y cada opción tiene su costo:
- En el código: la regla se revisa, versiona y prueba junto con la aplicación, pero cualquier cambio exige un deploy.
- En un archivo de configuración, como un JSON: cambia sin tocar el código, pero sigue necesitando un deploy y pierde la verificación del compilador.
- En la base de datos, por ruta: cambia sin deploy y se vuelve una fuente única, pero pasa a exigir una validación fuerte, porque una regla equivocada en la base de datos es una falla de seguridad en producción.
Solución: en la entrega, el requisito quedó declarado en el código de cada ruta, y los permisos posibles quedaron registrados en la base de datos. Eso trajo un cuidado nuevo: una ruta puede exigir un permiso que nadie registró. Y, si ese error solo aparece cuando alguien accede a la ruta, aparece demasiado tarde. Por eso, dejé registrado como próximo paso validar las reglas y el esquema del motor al arrancar la aplicación: si algo está mal, la aplicación ni siquiera arranca.
3. El costo de cada solicitud y la caché
Desafío: traer los datos cerca eliminó el viaje a la API central, pero no el viaje a la base de datos. En cada solicitud, la aplicación todavía tenía que leer las concesiones de la persona. Y las concesiones son justamente el tipo de dato que casi nunca cambia: alguien gana un rol, pierde otro, y nada más.
Entonces surge la pregunta: “¿Por qué buscar en la base de datos, en cada solicitud, algo que casi nunca cambia?” La respuesta es simple: no hace falta — siempre que la revocación siga valiendo al instante.
Solución: la caché de las concesiones quedó registrada como el próximo paso de la entrega, con una regla que considero innegociable: el plazo de la caché es solo una red de seguridad; quien invalida la caché es la revocación. Observa abajo cómo queda:
PLAZO_DE_CACHE = 300 # segundos: solo una red de seguridad def concesiones_del_usuario(usuario_id, cache, base): concesiones = cache.get(usuario_id) if concesiones is None: concesiones = base.concesiones_de(usuario_id) cache.set(usuario_id, concesiones, PLAZO_DE_CACHE) return concesiones def revocar(concesion_id, cache, base): usuario_id = base.revocar(concesion_id) # la revocación borra la caché de la persona al instante, sin esperar el plazo cache.delete(usuario_id)
Principales ventajas de este diseño:
- Lectura barata → la segunda consulta de la misma persona ya sale de la caché.
- Revocación inmediata → quien pierde un acceso lo pierde al instante, y no dentro de cinco minutos.
- Plazo como red de seguridad → si una invalidación falla, el daño dura como máximo el plazo de la caché.
Los riesgos de un motor genérico
Un motor genérico resuelve muchas cosas, pero trae riesgos que una regla escrita a mano, en un if, no tiene. Como el motor compara cualquier campo con cualquier comparador, un detalle de comparación se convierte en una brecha en todas las rutas al mismo tiempo.
Estos son los riesgos que aprendí a buscar en cualquier motor de este tipo. Para quien vaya a construir uno, creo que valen más que cualquier funcionalidad nueva.
#Case 1: el campo ausente que autoriza
En el primer artículo, dejé un comentario en el motor: un campo ausente nunca autoriza. Prometí volver a él, y aquí está el porqué. Observa la diferencia entre dos versiones de un comparador de igualdad:
def igual_ingenuo(exigido, concedido): return exigido == concedido def igual(exigido, concedido): return exigido is not None and exigido == concedido # la solicitud llegó sin organización, y la concesión tampoco tiene una igual_ingenuo(None, None) # True: autorizó sin saber sobre qué igual(None, None) # False
¿Qué está pasando aquí? Basta con un parámetro que no llegó en la solicitud y una concesión que no tiene ese campo para que la versión ingenua concluya que nada es igual a nada — y autorice. En un campo de aislamiento, como la organización, esa es exactamente la falla que OWASP pone en primer lugar. La regla que sigo es simple: la ausencia nunca autoriza.
#Case 2: los nombres que se vuelven iguales
Es tentador normalizar los nombres al momento de decidir: quitar espacios, ignorar mayúsculas, cambiar guion por guion bajo. El problema es que normalizar también es juntar nombres distintos:
def normalizar(nombre): return nombre.strip().lower().replace("-", "_").replace(" ", "_") normalizar("reporte-mensual") == normalizar("reporte_mensual") # True
En autorización, eso significa que una concesión para un recurso puede valer para otro. La salida que considero correcta es invertir el momento: el nombre se verifica cuando se guarda y se compara exactamente al momento de decidir. Observa el fragmento de abajo:
import re NOMBRE_VALIDO = re.compile(r"[a-z][a-z0-9_]*") def validar_nombre(nombre): # el nombre se verifica cuando se guarda, y no se arregla al momento de decidir if not NOMBRE_VALIDO.fullmatch(nombre): raise ValueError(f"nombre fuera del patrón: {nombre!r}") return nombre
Lo mismo vale para los alias “amigables”, como tratar
all como comodín. El comodín es el *, y solo él. Un alias escondido en la normalización es un comodín que nadie ve.#Case 3: “alguna” en lugar de “todas”
Cuando una solicitud involucra varios objetivos — una acción en lote sobre documentos de dos organizaciones, por ejemplo —, la pregunta correcta es si las concesiones cubren todos. Un comparador de “contiene” suele responder otra cosa:
def alguna(exigido, concedido): return any(objetivo in concedido for objetivo in exigido) def todas(exigido, concedido): return bool(exigido) and all(objetivo in concedido for objetivo in exigido) # la solicitud toca dos organizaciones; la concesión solo cubre una solicitud = ["org-a", "org-b"] concedido = ["org-a"] alguna(solicitud, concedido) # True: la solicitud pasaría a medias todas(solicitud, concedido) # False
Observa también el
bool(exigido): una solicitud sin ningún objetivo no puede considerarse cubierta. Es el mismo principio del campo ausente, apareciendo de otra forma.#Case 4: el error de configuración que solo aparece en la solicitud
En un motor guiado por esquema, el nombre de un comparador es solo un texto. Si está mal escrito, el error solo aparece cuando una solicitud pasa por ese campo — y aparece como un error interno, y no como un rechazo. La prevención es la misma del segundo desafío: validar el esquema completo al arrancar, rechazando cualquier comparador desconocido antes de la primera solicitud.
#Case 5: la prueba que no distingue “negó” de “se rompió”
Por último, un cuidado en las pruebas. Si una prueba de rechazo solo verifica que la respuesta fue un error 500, pasa tanto cuando el motor niega como cuando el código se rompe a mitad de camino. Un rechazo necesita un código propio, normalmente el 403 (Forbidden), y la prueba necesita verificar exactamente ese código. De lo contrario, la suite queda en verde justo el día en que algo sale mal.
Influir sin decidir
Hay un desafío que no aparece en el código, pero que atravesó toda esta entrega: ¿cómo proponer mejoras cuando la decisión no es tuya?
Yo era uno de los desarrolladores de la aplicación, y no la persona que define la arquitectura del ecosistema. La restricción de no tocar la API central llegó lista, y tenía sus razones: esa API atendía a todas las demás aplicaciones. Lo que yo podía decidir era cómo resolver el problema dentro de mi alcance. Y fue ahí donde nació el motor.
Algunos aprendizajes desde ese lugar:
- Lee la restricción como un requisito: en lugar de pelear con el límite, úsalo para diseñar la solución. Fue la restricción la que me llevó a separar el dato de la decisión.
- Entrega bien lo que es tuyo: una buena solución dentro del alcance convence más que una propuesta grande fuera de él.
- Deja el próximo paso donde la próxima persona lo va a encontrar: lo que quedó para después — la caché y la validación al arrancar — quedó registrado en el propio código, al lado de donde tendría que entrar.
- Muestra el costo antes de proponer el cambio: una arquitectura difícilmente cambia por una opinión, pero suele cambiar por un riesgo bien explicado.
Roadmap y próximos pasos
Para cerrar la serie, dejo lo que llevaría a la próxima versión de un motor como este:
- La ausencia nunca autoriza, con los campos de aislamiento siempre obligatorios.
- Nombres verificados al guardarse y comparados exactamente al decidir, sin alias de comodín.
- Esquema y reglas validados al arrancar, antes de la primera solicitud.
- Un comparador de cobertura para las solicitudes con varios objetivos.
- Un registro de cada decisión, con el campo que negó, para investigar un rechazo sin tener que adivinar.
- Pruebas basadas en propiedades, que generan miles de casos automáticamente para verificar reglas que nunca pueden romperse, como “una concesión de una organización nunca alcanza a otra”.
Conclusión
Empecé este artículo con OWASP diciendo que la pérdida de control de acceso es la falla más común en la web. Después de construir este motor, creo que entiendo mejor el porqué: la autorización está hecha de detalles, y cada detalle — un campo que no llegó, un nombre normalizado, un “alguna” en lugar de “todas” — puede abrir una puerta en todas las rutas a la vez.
Pero la lección más importante de esta serie, para mí, no es técnica. Ninguno de los tres artículos habla de un proyecto ideal, construido desde cero, con libertad total. Hablan de una tarea con una restricción clara, en un sistema que ya existía, y de cómo es posible entregar algo bueno — y honesto sobre sus propios límites — sin decidir la arquitectura alrededor.
Como escribí en el primer artículo de este blog, la habilidad de transmitir conocimiento es tan importante como poseerlo, o incluso más. Y contar los desafíos, y no solo los logros, también es transmitir conocimiento.
Si tú también tuviste que entregar dentro de límites que no elegiste, te invito a compartir tu experiencia. Al fin y al cabo, así es como una comunidad aprende junta.
Yago MarinhoIngeniero de Software Sénior. Construyo productos con IA, del diseño al despliegue.