Production AI Infrastructure

Migrar una aplicación existente a Heimdall + OpenFGA, una ruta a la vez

Parte 2: una ruta de adopción con el patrón strangler fig para migrar una aplicación en producción a un gateway Zero-Trust y OpenFGA, empezando con una sola ruta en modo sombra y expandiéndose sin reescribir todo de golpe.

17 min de lectura Actualizado el 19 jun 2026

Resumen

  • La parte difícil del patrón Heimdall + OpenFGA no es el diagrama de un proyecto nuevo, sino migrar una aplicación en producción sin una transición abrupta que dé miedo.
  • La forma segura es el patrón strangler fig (Martin Fowler): rodear el sistema anterior con una fachada (tu gateway) y migrar una ruta a la vez hasta «estrangular» la autorización heredada.
  • La técnica de migración más importante es el modo sombra: ejecutar OpenFGA en paralelo con tus verificaciones existentes, comparar las respuestas, registrar las discrepancias y aplicar las decisiones solo cuando confíes en los resultados.
  • Un truco útil de Heimdall: ejecutar en sombra con un contextualizer (observa, nunca bloquea) y luego cambiar a un authorizer (aplica la decisión y falla de forma cerrada) para la misma verificación.
  • Carga tus permisos existentes en OpenFGA como tuplas de relación con el comando fga tuple write --file y mantenlos sincronizados mediante dual-write durante el período de coexistencia.
  • Haz que cada fase sea reversible: toggles por ruta, fallo abierto durante el modo sombra y fallo cerrado solo después de la validación.
  • Esta es la Parte 2. La Parte 1 construye la arquitectura desde cero: El stack de autorización Zero-Trust que convence a los arquitectos: Heimdall + OpenFGA. Para los detalles del modelado en OpenFGA, consulta OpenFGA para permisos granulares.

Qué aprenderás aquí

  • Por qué nunca deberías migrar la autorización de golpe y qué hacer en su lugar
  • Cómo se aplica el patrón strangler fig a un despliegue de gateway + PDP
  • Una escalera de adopción concreta que puedes seguir fase por fase
  • Cómo ejecutar OpenFGA en modo sombra tanto en la aplicación como en el gateway
  • Cómo cargar los permisos existentes en OpenFGA como tuplas
  • Cómo mantener sincronizados el sistema heredado y OpenFGA durante la coexistencia (dual-write + reconciliación)
  • Cómo hacer que cada paso sea reversible con toggles por ruta
  • Los errores de migración que afectan a los equipos y una lista de verificación para evitarlos

Dónde encaja esto

En la Parte 1 construimos el patrón convergente desde cero:

Client → Gateway (forwardAuth) → Heimdall (PEP + token broker) → OpenFGA (PDP)
                                       │
                                       └─ mints internal JWT → thin upstream service

Eso es fácil cuando aún no existe una aplicación. La realidad suele ser distinta: tienes un servicio en funcionamiento con autenticación integrada, verificaciones de permisos dispersas por el código, usuarios reales y ninguna intención de pasar un fin de semana en el que todo podría fallar.

Este artículo trata de migrar esa aplicación al patrón de forma segura y reversible, sin pedirle a nadie que confíe en una reescritura con cambio de golpe.

El principio: strangler fig para autorización

El patrón strangler fig de Martin Fowler recibe su nombre de una planta trepadora que crece alrededor de un árbol y lo reemplaza lentamente. Aplicado al software, evita el enfoque de alto riesgo de «reescribir todo y luego hacer el cambio»: construyes el nuevo sistema alrededor del anterior y migras la funcionalidad gradualmente hasta que puedas retirar el sistema heredado.

El patrón tiene tres fases:

  • Transformar — construir la nueva capacidad (gateway + PDP) junto a la anterior.
  • Coexistir — ambos sistemas funcionan en paralelo; una fachada enruta las solicitudes. Puedes pausar o revertir en cualquier momento.
  • Eliminar — una vez que todo esté migrado y validado, retirar el flujo heredado.

La autorización es una candidata ideal para esto, porque el gateway es una fachada natural y las decisiones de autorización son fáciles de ejecutar en paralelo y comparar. El nuevo sistema puede observar al anterior en modo sombra y demostrar que funciona con tráfico real antes de tomar el control.

        BEFORE                         DURING (coexist)                AFTER
   ┌───────────────┐            ┌──────────────────────┐         ┌───────────────┐
   │  App with      │           │  Gateway (facade)     │         │  Gateway      │
   │  inline auth   │           │   ├─ migrated routes ─┼─► PDP   │   └─ all routes├─► PDP
   │  checks        │  ───────► │   └─ legacy routes ───┼─► app   │               │
   └───────────────┘           └──────────────────────┘         └───────────────┘
                                   one route moves at a time         legacy auth gone

Punto de partida: la aplicación que realmente tienes

Sé realista sobre el punto de partida. Un servicio típico se ve así:

Client ──[login token]──► App
                            │  validateSession()
                            │  if (user.role !== "admin" && doc.ownerId !== user.id) → 403
                            │  ...business logic...
                            ▼
                          Database

Dentro de la aplicación hay dos cosas entrelazadas:

  1. Autenticación — «¿quién es?» (validación de sesión/JWT)
  2. Autorización — «¿puede hacer esto?» (las verificaciones if dispersas)

La migración separa ambas responsabilidades y las mueve hacia afuera: la autenticación y la decisión pasan al gateway/PDP, y la aplicación se ocupa únicamente de la lógica de negocio. Lo hacemos una ruta a la vez.

La escalera de adopción

Esto sigue la ruta de adopción paso a paso del artículo sobre modelado en OpenFGA, pero para todo el stack. Sube un peldaño a la vez; puedes detenerte y permanecer en cualquiera de ellos durante un tiempo.

Phase 0  Inventory: build the route → permission map
Phase 1  Put the gateway in front, pass-through only (no enforcement)
Phase 2  One route: authenticate at the edge, mint internal JWT, keep app checks
Phase 3  Shadow the decision with OpenFGA (compare, log, do NOT enforce)
Phase 4  Enforce that one route's decision at the gateway (fail closed)
Phase 5  Make the service thin: delete the now-redundant inline check
Phase 6  Expand route by route, service by service
Phase 7  Backfill tuples + add model governance
Phase 8  Decommission legacy auth

Fase 0 — Inventario

No puedes migrar lo que no has documentado. Crea un mapa de rutas y permisos. Es una tarea aburrida, pero es el artefacto más valioso de todo el proyecto.

RutaMétodoVerificación actual (en el código)Relación de destinoObjeto de destino
/document/:idGETrole==admin OR ownerreaderdocument:{id}
/document/:idDELETErole==admin OR ownerownerdocument:{id}
/documentsGETtenant filterreader (lista)document

Esta tabla se convertirá después en tus reglas de Heimdall y en tu modelo de OpenFGA; además, sirve como especificación para las comparaciones en modo sombra.

Fase 1 — Gateway al frente, sin intervención

Coloca el gateway (Traefik, Caddy, NGINX, Envoy) delante de la aplicación y deja pasar todo directamente. Sin aplicar reglas ni tomar decisiones. Solo estás introduciendo la fachada.

Client ──► Gateway ──► App (still does all its own auth)

Para la versión nativa del gateway, Heimdall puede ejecutarse con una regla predeterminada que simplemente permite el paso y no hace nada más: el autenticador anonymous junto con un finalizador noop:

# heimdall: pass-through default rule (Phase 1)
default_rule:
  execute:
    - authenticator: anon      # creates a principal, allows everything
    - finalizer: noop          # changes nothing

El objetivo de esta fase es demostrar que la fachada es estable, observar la latencia y confirmar que nada falla. Has cambiado la topología sin cambiar el comportamiento.

Fase 2 — Una ruta: autenticar en el borde y emitir el token

Elige una ruta de bajo riesgo (una GET es ideal). Solo para esa ruta, mueve la autenticación al gateway: valida el token de inicio de sesión y emite el JWT interno. Mantén la verificación de autorización propia de la aplicación. Ahora hay dos componentes que validan la identidad; esa redundancia es intencional y segura.

Client ──[login JWT]──► Heimdall
                          │ jwt_auth: validate login token
                          │ create_jwt: mint internal JWT
                          ▼
                        App (still runs its own if-checks)
# rules: one route, authn + token only (Phase 2)
- id: doc_read
  match:
    routes:
      - path: /document/:id
    methods: [GET]
  forward_to:
    host: upstream:8081
  execute:
    - authenticator: jwt_auth
    - finalizer: create_jwt

La aplicación sigue funcionando exactamente igual que antes. Acabas de comprobar el intercambio de emisión del token en una ruta real.

Fase 3 — Comparar la decisión en modo sombra (el peldaño crítico)

Ahora incorpora OpenFGA, pero todavía no permitas que bloquee nada. Esta es la técnica que recomienda la propia guía de adopción de OpenFGA: ejecuta ambos sistemas, usa el sistema existente como fuente de verdad, llama a OpenFGA en paralelo y registra cada discrepancia.

Hay dos formas claras de implementar el modo sombra.

Opción A — modo sombra dentro de la aplicación (el primer paso más sencillo). Tu verificación existente sigue siendo la autoridad; agregas una llamada asíncrona a OpenFGA y comparas.

async function canRead(user: User, docId: string): Promise<boolean> {
  // Legacy decision — still authoritative
  const legacy = user.role === "admin" || (await isOwner(user.id, docId));

  // Shadow decision — observe only
  fga
    .check({ user: `user:${user.id}`, relation: "reader", object: `document:${docId}` })
    .then(({ allowed }) => {
      if (allowed !== legacy) {
        logger.warn("fga_shadow_mismatch", {
          route: "GET /document/:id",
          userId: user.id,
          docId,
          legacy,
          fga: allowed,
        });
      }
    })
    .catch((err) => logger.error("fga_shadow_error", { err }));

  return legacy; // legacy still decides
}

Opción B — modo sombra en el gateway con un contextualizer. Esta es una propiedad útil de Heimdall: un contextualizer llama a /check de OpenFGA y enriquece la solicitud, pero, a diferencia de un authorizer, no hace fallar el pipeline cuando la respuesta es false. Así puedes registrar la decisión (en el JWT emitido o en los logs) y seguir dejando pasar la solicitud. Cuando estés listo para aplicar la decisión, cambias esa misma llamada de un contextualizer a un authorizer.

# Phase 3: observe with a contextualizer (does NOT block)
- id: doc_read
  match:
    routes:
      - path: /document/:id
    methods: [GET]
  forward_to:
    host: upstream:8081
  execute:
    - authenticator: jwt_auth
    - contextualizer: openfga_check_shadow   # logs/embeds the decision, never blocks
      config:
        values:
          store_id: <store_id>
          model_id: <model_id>
          relation: reader
          object: document:{{ .Request.URL.Captures.id }}
    - finalizer: create_jwt
Client ──► Heimdall ──┬─ jwt_auth (enforced)
                      ├─ openfga shadow check (observe only) ──► OpenFGA
                      └─ mint JWT ──► App (legacy check still authoritative)
                                          │
                                   log mismatch if app ≠ OpenFGA

Permanece en este peldaño hasta que la tasa de discrepancias sea prácticamente cero. Cada discrepancia revela un error en tu modelo, tus tuplas o tu comprensión de la regla heredada; conviene encontrarlos mientras OpenFGA aún no puede bloquear nada.

El modo sombra es el seguro más barato de toda la migración. El registro de discrepancias es tu suite de pruebas contra el tráfico de producción.

Fase 4 — Aplicar la decisión en el gateway (fallo cerrado)

Cuando los resultados en sombra de la ruta coincidan, pasa la verificación de contextualizer a authorizer. Ahora la decisión de OpenFGA se aplica en el borde y la regla falla de forma cerrada: si OpenFGA deniega el acceso (o no está disponible), la solicitud se rechaza antes de llegar a la aplicación.

# Phase 4: enforce with an authorizer (fails closed)
  execute:
    - authenticator: jwt_auth
    - authorizer: openfga_check        # was a contextualizer in Phase 3
      config:
        values:
          store_id: <store_id>
          model_id: <model_id>
          relation: reader
          object: document:{{ .Request.URL.Captures.id }}
    - finalizer: create_jwt

En este punto, la aplicación todavía tiene su verificación en el código. Está bien: es defensa en profundidad durante una fase más. Controla la aplicación de la decisión con un toggle por ruta para poder volver al modo sombra de inmediato si algo parece estar mal.

Fase 5 — Simplificar el servicio

Solo ahora, después de comprobar que el gateway aplica las decisiones correctamente, elimina de la aplicación la verificación redundante en el código para esa ruta. El servicio pasa de esto:

app.get("/document/:id", async (req, res) => {
  const user = await requireSession(req);
  if (user.role !== "admin" && !(await isOwner(user.id, req.params.id))) {
    return res.sendStatus(403);            // ← delete this block
  }
  res.json(await getDocument(req.params.id));
});

a esto:

// Gateway already authorized + minted an internal JWT.
app.get("/document/:id", verifyInternalJwt, async (req, res) => {
  res.json(await getDocument(req.params.id));
});

verifyInternalJwt solo valida la firma, el emisor y la expiración de Heimdall (consulta la sección de verificación downstream en la Parte 1). Ahora la ruta es realmente ligera: sin roles, sin lógica de propiedad y sin ramificaciones de autorización.

Fase 6 — Expandir, ruta por ruta

Repite las Fases 2–5 para cada ruta siguiente, priorizando según el riesgo y el valor. El strangler fig crece: más rutas detrás del gateway y menos verificaciones en el código de la aplicación. Obtienes valor continuamente y puedes pausar cuando surja una prioridad mayor.

week 1:  GET /document/:id            ✅ enforced + thin
week 2:  DELETE /document/:id         ✅ enforced + thin
week 3:  GET /documents (list)        🔵 shadowing
week 4:  /projects/* ...              ⏳ inventory done

Fase 7 — Carga inicial de tuplas + gobernanza

Para que los resultados del modo sombra coincidan, OpenFGA necesita los datos: tus permisos existentes, expresados como tuplas de relación. Expórtalos desde tu fuente de verdad actual (filas de la base de datos, tablas de roles, ACLs) e impórtalos con el CLI de FGA:

# tuples.json generated from your existing permissions tables
fga tuple write --file tuples.json \
  --store-id <store_id> \
  --model-id <model_id> \
  --max-tuples-per-write 40 \
  --max-parallel-requests 8

Un pequeño exportador que convierte filas de la base de datos en tuplas:

// Build tuples.json from existing ownership/role data
const tuples = [];
for (const doc of await db.documents.findMany()) {
  tuples.push({ user: `user:${doc.ownerId}`, relation: "owner", object: `document:${doc.id}` });
}
for (const m of await db.orgAdmins.findMany()) {
  tuples.push({ user: `user:${m.userId}`, relation: "admin", object: `organization:${m.orgId}` });
}
await fs.writeFile("tuples.json", JSON.stringify(tuples, null, 2));

Verifica con fga tuple read y vuelve a ejecutar el modo sombra. Es momento de establecer estas prácticas de gobernanza:

  • Fija el ID del modelo. Los modelos de OpenFGA son inmutables; cada escritura crea una nueva versión. Pasa siempre --model-id / authorization_model_id para que producción sea determinista.
  • Haz cambios de modelo compatibles con versiones anteriores. Para renombrar o dividir una relación, agrega la nueva relación, copia las tuplas, despliega y luego elimina la anterior; nunca la renombres directamente.
  • Un solo servicio escribe tuplas. Decide qué servicio tiene permitido escribir tuplas para que las relaciones no se desincronicen.

Fase 8 — Retirar la autorización heredada

Cuando todas las rutas apliquen las decisiones en el gateway y sean ligeras en el servicio, elimina el código de autorización heredado, las capas de adaptación para dual-write y los antiguos helpers de verificación de roles. La higuera ha estrangulado al árbol. Conserva el emisor de autenticación (tu IdP): estás retirando la autorización dispersa, no el inicio de sesión.

Coexistencia: mantener dos sistemas sincronizados

El período más delicado comprende las Fases 3–7, cuando coexisten el sistema heredado y OpenFGA. Dos prácticas ayudan a mantenerlos sincronizados:

Dual-write. Cada vez que cambie un permiso (al compartir un recurso, asignar un rol o transferir la propiedad), escríbelo tanto en el almacenamiento heredado como en OpenFGA dentro de la misma operación. De lo contrario, OpenFGA se desincroniza y las discrepancias en modo sombra se multiplican.

async function shareDocument(docId: string, targetUserId: string, role: Role) {
  await db.documentShares.create({ docId, targetUserId, role }); // legacy
  await fga.write({
    writes: [{ user: `user:${targetUserId}`, relation: role, object: `document:${docId}` }],
  }); // OpenFGA
}

Reconciliación periódica. Incluso con dual-write se pueden pasar por alto relaciones (por fallos, ediciones directas en la base de datos o ciclos de actualización y reversión en entornos on-prem). Ejecuta una tarea programada que vuelva a derivar las tuplas desde la fuente de verdad, las compare con OpenFGA y corrija las diferencias. No confíes únicamente en scripts de migración que se ejecutan una sola vez.

Haz que todo sea reversible

La razón de ser del patrón strangler fig es que puedes detenerte o dar marcha atrás. Incorpóralo desde el principio:

  • Toggle por ruta. Un flag de configuración (shadow | enforce | legacy) por ruta te permite subir o bajar peldaños sin hacer un deploy.
  • Fallo abierto durante el modo sombra, cerrado después. Durante las Fases 1–3, una caída del PDP no debe interrumpir el tráfico. A partir de la Fase 4, una caída del PDP debe denegar el acceso: ese es el contrato Zero-Trust.
  • Mantén la verificación en el código una fase más de lo que crees necesario. Elimínala (Fase 5) solo después de que la ruta haya funcionado sin problemas con las decisiones aplicadas durante un período real de observación.

Errores que debes evitar

  • Cambio de golpe. Pasar toda la aplicación a OpenFGA de una sola vez elimina toda la red de seguridad. Una ruta a la vez.
  • Aplicar decisiones antes de observarlas en sombra. Si no has comparado las decisiones con tráfico real, vas a bloquear a usuarios reales. Primero el modo sombra, siempre.
  • Olvidar el dual-write. Una carga inicial sin sincronización continua queda desactualizada en cuestión de horas. Las discrepancias parecerán errores del modelo, pero en realidad serán desincronización.
  • Explosión de tuplas. «La mejor tupla es la que no tienes que escribir». Deriva los permisos del modelo cuando sea posible en lugar de materializar cada concesión.
  • Renombrar relaciones directamente. Los modelos son inmutables y las aplicaciones pueden apuntar a versiones anteriores; migra siempre las relaciones de forma compatible con versiones anteriores.
  • Confiar para siempre en la verificación del código de la aplicación. El objetivo final es un servicio ligero. Si nunca eliminas las verificaciones heredadas, tendrás que mantener dos sistemas de autorización, no uno.

Lista de verificación de la migración

FaseSe completa cuando
0 InventarioCada ruta está asociada a una relación + objeto de destino
1 FachadaEl gateway está al frente, deja pasar el tráfico y la latencia es aceptable
2 Autenticación en el bordeUna ruta valida el token + emite un JWT interno; la aplicación no cambia
3 Modo sombraSe llama a OpenFGA en paralelo; tasa de discrepancias ≈ 0 en los logs
4 Aplicación de decisionesEl gateway falla de forma cerrada para la ruta; existe un toggle para revertir
5 Servicio ligeroSe eliminó la verificación del código; la ruta pasó un período de observación sin problemas
6 ExpansiónLas siguientes rutas avanzan por la misma escalera
7 Carga inicialLas tuplas están importadas, el modelo está fijado y dual-write + reconciliación están activos
8 RetiroSe eliminó el código de autorización heredado; solo queda el IdP

Modelo mental final

Una migración no es un momento, es un gradiente:

legacy decides  ──►  both decide (compare)  ──►  OpenFGA decides  ──►  service is thin
   Phase 2            Phase 3 (shadow)            Phase 4 (enforce)     Phase 5

Cada ruta recorre ese gradiente de forma independiente, con tráfico real y con un toggle para volver atrás en cada paso. El destino no tiene nada de novedoso: es el mismo stack convergente de la Parte 1. Lo que hace que la migración sea llevadera es negarse a llegar allí de un solo salto. Deja que el nuevo sistema demuestre que funciona en las sombras, ruta por ruta, hasta que el anterior desaparezca silenciosamente.

Fuentes