Corrección de errores de dependencia circular de CloudFormation en AWS Amplify Gen 2.

Log de despliegue de AWS Amplify con el error de dependencia circular de CloudFormation entre los stacks anidados storage, auth, data y function

RESUMEN

Cuando tu función Lambda necesita acceso a Cognito Y se usa como controlador de datos, usa resourceGroupName: "auth" en tu definición de función para evitar dependencias circulares.

El Problema

Si estás construyendo con AWS Amplify Gen 2 y creando funciones Lambda que interactúan con Cognito (para gestión de usuarios, operaciones de grupo, etc.) mientras las usas como resolvers de GraphQL, probablemente hayas encontrado este error frustrante:

[ERROR] [CloudformationStackCircularDependencyError]
The CloudFormation deployment failed due to circular dependency found
between nested stacks [storage0YH5D, auth17956DE9, data74490FG5R, function164FR5G3]

Esto sucede porque Amplify Gen 2 organiza los recursos en stacks CloudFormation anidados separados:

  • Auth stack - Cognito User Pool, grupos, triggers.
  • Data stack - AppSync API, tablas DynamoDB, resolvers.
  • Function stack - Funciones Lambda (por defecto).
  • Storage stack - Buckets S3.

Cuando tu función necesita recursos de múltiples stacks, CloudFormation no puede determinar el orden de despliegue, creando una dependencia circular.

Escenario del mundo real

Estaba construyendo un panel de administración para gestión de roles de usuarios. Las funciones Lambda necesitaban:

  • Llamar a APIs de Cognito (AdminAddUserToGroup, ListUsersInGroup, etc.).
  • Ser usadas como controladores de mutaciones/consultas de GraphQL en el esquema de datos.

Así es como se veía mi configuración inicial:

// amplify/functions/add-user-to-group/resource.ts
export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
});

// amplify/auth/resource.ts
export const auth = defineAuth({
  // ...
  access: (allow) => [
    allow.resource(addUserToGroup).to(["addUserToGroup", "listUsers"]),
  ],
});

// amplify/data/resource.ts - using the function as a handler
addUserToGroup: a
  .mutation()
  .handler(a.handler.function(addUserToGroup))
  .authorization((allow) => [allow.group("ADMINS")]),

Resultado: Error de dependencia circular. El stack de auth importa la función, el stack de datos importa la función, y el stack de funciones depende de ambos 💥.

La solución: resourceGroupName

Amplify Gen 2 proporciona una propiedad simple pero poderosa: resourceGroupName. Esto le dice a Amplify en qué stack anidado colocar tu función.

La corrección

// amplify/functions/add-user-to-group/resource.ts
export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
  resourceGroupName: "auth", // ← This is the magic line
});

Al colocar la función en el stack auth, rompemos la dependencia circular:

  • La función es ahora parte de el stack de auth, no una dependencia separada.
  • El stack de datos aún puede referenciarla como controlador.
  • Los permisos de auth se otorgan dentro del mismo stack.

Ejemplo completo de trabajo 1

1. Definición de función

// amplify/functions/add-user-to-group/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
  timeoutSeconds: 30,
  memoryMB: 256,
  resourceGroupName: "auth", // Place in auth stack
});

2. Recurso de auth con otorgamiento de acceso

// amplify/auth/resource.ts
import { defineAuth } from "@aws-amplify/backend";
import { addUserToGroup } from "../functions/add-user-to-group/resource";

export const auth = defineAuth({
  loginWith: { email: true },
  groups: ["ADMINS", "EDITORS"],
  access: (allow) => [
    allow.resource(addUserToGroup).to(["addUserToGroup", "listUsers"]),
  ],
});

3. Esquema de datos usando la función

// amplify/data/resource.ts
import { addUserToGroup } from "../functions/add-user-to-group/resource";

const schema = a.schema({
  addUserToGroup: a
    .mutation()
    .arguments({
      email: a.string().required(),
      groupName: a.string().required(),
    })
    .returns(
      a.customType({
        success: a.boolean(),
        message: a.string(),
      })
    )
    .handler(a.handler.function(addUserToGroup))
    .authorization((allow) => [allow.group("ADMINS")]),
});

4. Controlador Lambda

// amplify/functions/add-user-to-group/handler.ts
import {
  CognitoIdentityProviderClient,
  AdminAddUserToGroupCommand,
  ListUsersCommand,
} from "@aws-sdk/client-cognito-identity-provider";
import { env } from "$amplify/env/add-user-to-group";

const cognitoClient = new CognitoIdentityProviderClient({});

export const handler = async (event) => {
  const { email, groupName } = event.arguments;

  // Look up user by email
  const listUsersResponse = await cognitoClient.send(
    new ListUsersCommand({
      UserPoolId: env.AMPLIFY_AUTH_USERPOOL_ID, // Auto-injected by Amplify
      Filter: `email = "${email}"`,
    })
  );

  const username = listUsersResponse.Users?.[0]?.Username;

  // Add to group
  await cognitoClient.send(
    new AdminAddUserToGroupCommand({
      UserPoolId: env.AMPLIFY_AUTH_USERPOOL_ID,
      Username: username,
      GroupName: groupName,
    })
  );

  return { success: true, message: `User added to ${groupName}` };
};

5. No olvides el package.json

// amplify/functions/add-user-to-group/package.json
{
  "name": "add-user-to-group",
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.0.0"
  }
}

¡Ejecuta npm install en el directorio de la función!

Ejemplo 2: Dependencia circular de Storage + Data

Otro escenario común es cuando una función Lambda necesita acceder tanto a Storage (S3) como a Data (DynamoDB). Esto ocurre frecuentemente con trabajos de limpieza programados, pipelines de procesamiento de archivos, o cualquier función que gestione archivos mientras rastrea estado en una base de datos.

El escenario

Estaba construyendo una función de limpieza programada que:

  • Consulta DynamoDB para encontrar sesiones de grabación de voz antiguas
  • Elimina los archivos de audio asociados de S3
  • Actualiza los registros de sesión para limpiar las referencias de grabación

La configuración inicial (rota)

Mi primer intento usó allow.resource() en la definición de storage:

// amplify/storage/resource.ts
import { defineStorage } from "@aws-amplify/backend";
import { recordingCleanup } from "../functions/recording-cleanup/resource";

export const storage = defineStorage({
  name: "my-app-storage",
  access: (allow) => ({
    "audio/*": [
      allow.authenticated.to(["read", "write"]),
      allow.resource(recordingCleanup).to(["read", "delete"]), // ← Problem!
    ],
  }),
});
// amplify/functions/recording-cleanup/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const recordingCleanup = defineFunction({
  name: "recording-cleanup",
  entry: "./handler.ts",
  schedule: "every week",
  resourceGroupName: "data", // Function needs DynamoDB access
});
// amplify/backend.ts - granting DynamoDB permissions
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem"],
    resources: [backend.data.resources.tables["Session"].tableArn],
  })
);

Resultado: Error de dependencia circular entre los stacks storage y data 💥

Por qué falla

La cadena de dependencia crea un bucle:

  1. Storage importa la función recordingCleanup → storage depende de la función
  2. Function está en el stack data y referencia tablas de datos en backend.ts → el stack data está involucrado
  3. backend.ts referencia backend.storage para la función → data depende de storage

CloudFormation no puede resolver esta referencia circular.

La corrección: elige el stack primario, otorga el secundario vía CDK

La solución es remover la dependencia de importación de storage y otorgar permisos de S3 vía CDK:

// amplify/storage/resource.ts - NO function import
import { defineStorage } from "@aws-amplify/backend";

export const storage = defineStorage({
  name: "my-app-storage",
  access: (allow) => ({
    "audio/*": [
      allow.authenticated.to(["read", "write"]),
      // Function access granted via CDK in backend.ts
    ],
  }),
});
// amplify/functions/recording-cleanup/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const recordingCleanup = defineFunction({
  name: "recording-cleanup",
  entry: "./handler.ts",
  schedule: "every week",
  resourceGroupName: "data", // Keep in data stack (primary resource)
});
// amplify/backend.ts - grant BOTH permissions via CDK
import { PolicyStatement } from "aws-cdk-lib/aws-iam";

// DynamoDB permissions
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem"],
    resources: [
      backend.data.resources.tables["Session"].tableArn,
      `${backend.data.resources.tables["Session"].tableArn}/index/*`,
    ],
  })
);

// S3 permissions (instead of allow.resource())
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["s3:DeleteObject", "s3:GetObject", "s3:ListBucket"],
    resources: [
      backend.storage.resources.bucket.bucketArn,
      `${backend.storage.resources.bucket.bucketArn}/*`,
    ],
  })
);

// Pass bucket name as environment variable
(
  backend.recordingCleanup.resources
    .lambda as import("aws-cdk-lib/aws-lambda").Function
).addEnvironment(
  "STORAGE_BUCKET_NAME",
  backend.storage.resources.bucket.bucketName
);
// amplify/functions/recording-cleanup/handler.ts
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3Client = new S3Client({});
const BUCKET_NAME = process.env.STORAGE_BUCKET_NAME!;

export const handler = async () => {
  // Query DynamoDB for old sessions...
  // Delete from S3 using BUCKET_NAME...
};

Por qué funciona

Al remover la importación allow.resource() de storage:

  • Storage ya no depende de la definición de la función
  • La función permanece en el stack data (su recurso primario)
  • Los permisos de S3 fluyen en una dirección: stack data → recursos storage
  • ¡Sin dependencia circular!

Actualización de guía de decisión

Función necesita...resourceGroupNameOtorgar acceso vía
Operaciones de Cognito"auth"Callback auth.access
API de datos / AppSync"data"Schema .authorization()
Solo Storage (S3)"storage"storage.access con allow.resource()
Both Data AND Storage"data"Data vía schema, S3 vía CDK
Auth AND Data handler"auth"Auth vía callback access

Conclusión clave

Cuando tu función necesita múltiples tipos de recursos, elige el stack primario y otorga permisos secundarios vía CDK. El patrón allow.resource() es conveniente pero crea dependencias de importación que pueden causar referencias circulares. addToRolePolicy() de CDK crea dependencias IAM unidireccionales que CloudFormation puede resolver.

Guía de decisión

Tu función necesita...Usa resourceGroupNamePor qué
Operaciones de Cognito"auth"La función se une al stack auth, obtiene permisos vía callback access
API de datos / AppSync"data"La función se une al stack data, obtiene permisos vía schema authorization
Solo Storage (S3)"storage"storage.access con allow.resource()
Tanto Cognito como handler de datos"auth"Prioriza auth ya que los permisos de Cognito requieren el callback access
Tanto Data como Storage"data"Data vía schema, S3 vía CDK
Trigger de auth (pre-signup, etc.)"auth"Debe estar en el mismo stack que el recurso auth
Solo acceso a S3(default)Usa políticas IAM en backend.ts

Errores comunes a evitar

❌ No otorgues políticas IAM manualmente cuando el callback access funciona

// DON'T DO THIS for auth operations
backend.myFunction.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["cognito-idp:AdminAddUserToGroup"],
    resources: [userPoolArn],
  })
);

✅ Sí usa el callback access nativo de Amplify

// DO THIS instead
export const auth = defineAuth({
  access: (allow) => [allow.resource(myFunction).to(["addUserToGroup"])],
});

El callback access:

  • Automáticamente inyecta la variable de entorno AMPLIFY_AUTH_USERPOOL_ID.
  • Otorga permisos IAM de menor privilegio.
  • Funciona sin problemas con resourceGroupName: "auth".

Consejos de depuración

  • Verifica tus imports - Asegúrate de que auth/resource.ts y data/resource.ts estén importando desde el mismo archivo de definición de función.

  • Verifica resourceGroupName - El valor debe ser exactamente "auth" o "data" (strings en minúsculas).

  • Limpia la sandbox - A veces necesitas eliminar y recrear: npx ampx sandbox delete && npx ampx sandbox.

  • Verifica CloudFormation - En AWS Console, mira los stacks anidados para ver dónde terminaron los recursos.

Prevención de este error con documentos de dirección de Kiro

Después de golpear este error múltiples veces, decidí crear un documento de dirección usando Kiro - un IDE potenciado por IA que soporta orientación contextual a través de archivos de dirección.

¿Qué son los documentos de dirección?

Los documentos de dirección son archivos markdown que proporcionan orientación consciente del contexto a los asistentes de IA. Viven en tu directorio .kiro/steering/ y pueden ser:

  • Siempre incluidos - Aplicados a cada interacción de IA.
  • Emparejados por archivo - Solo incluidos cuando se trabaja en archivos específicos.
  • Manual - Incluidos bajo demanda vía claves de contexto.

Creación de un documento de dirección de prevención de dependencia circular

Creé .kiro/steering/amplify-circular-dependency.md con emparejamiento de archivos:

---
inclusion: fileMatch
fileMatchPattern: "amplify/**/*.ts"
---

# Amplify Gen 2 Circular Dependency Prevention

## Checklist Before Creating Lambda Functions

- [ ] Does this function need Cognito access? → Use `resourceGroupName: "auth"`
- [ ] Does this function need Data API access? → Use `resourceGroupName: "data"`
- [ ] Is this function used as a data handler AND needs Cognito? → Use `resourceGroupName: "auth"`

## Decision Matrix

| Function needs...  | resourceGroupName | Grant access via          |
| ------------------ | ----------------- | ------------------------- |
| Cognito operations | `"auth"`          | `auth.access` callback    |
| Data API / AppSync | `"data"`          | Schema `.authorization()` |
| Storage (S3) only  | (default)         | `backend.ts` IAM policy   |
| Auth trigger       | `"auth"`          | `auth.triggers`           |

Cómo funciona

Ahora, cuando trabajo (o el asistente de IA trabaja) en cualquier archivo del directorio amplify/, Kiro automáticamente incluye esta orientación. La IA recuerda:

  • Verificar si la función necesita acceso a Cognito.
  • Usar el resourceGroupName correcto.
  • Otorgar permisos vía el mecanismo apropiado.

Esto ha eliminado completamente los errores de dependencia circular de mi flujo de trabajo. La IA detecta el problema antes de que suceda, no después de un despliegue fallido.

Por qué importa

  • Prevención proactiva - Detecta problemas antes de que causen fallas de despliegue.
  • Compartición de conocimiento del equipo - Los nuevos miembros del equipo obtienen la misma orientación.
  • Desarrollo asistido por IA - La IA aprende los patrones de tu proyecto.
  • Documentación viva - Los documentos de dirección evolucionan con tu base de código.

Consulta Kiro para agregar documentos de dirección a tus propios proyectos y prevenir errores recurrentes como este.

Conclusión

La propiedad resourceGroupName es una herramienta simple pero esencial para gestionar backends complejos de Amplify Gen 2. Cuando tus funciones Lambda necesitan interactuar con múltiples recursos de Amplify (auth, data, storage), piensa en cuál stack pertenecen para evitar dependencias circulares.

Regla general: Si tu función necesita acceso a Cognito, colócala en el stack auth. Si necesita acceso a Data API, colócala en el stack data. Si necesita ambos, auth generalmente gana porque los permisos de Cognito requieren el callback access.

Y si quieres prevenir este error de que nunca vuelva a suceder, usa documentos de dirección de Kiro para codificar este conocimiento directamente en tu flujo de trabajo de desarrollo.

Referencias

  • Los Atributos de AWS Cognito User Pool No Pueden Ser Cambiados: Un Análisis Profundo.

    Los atributos del pool de usuarios no pueden ser cambiados después de que se ha creado un pool de usuarios porque los atributos de Cognito User Pool son inmutables después de la creación. Fusionar ramas con diferentes configuraciones de autenticación rompe los despliegues. Solución Rápida: Elimina la pila de CloudFormation (aws cloudformation delete-stack --stack-name YOUR-STACK) y redespliega. Advertencia: esto elimina todos los usuarios. Prevenlo: Define todos los atributos de usuario y grupos antes de tu primer despliegue en producción. Usa aplicaciones Amplify separadas por entorno en lugar de despliegues basados en ramas.

  • No se puede resolver amplify_outputs.json

    Para corregir el error de compilación "no se puede resolver amplify_outputs.json", añade npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID a la configuración de compilación y adjunta la política AdministratorAccess-Amplify

  • Actualización asincrónica de elementos relacionados con AWS DataStore en una aplicación web Next.js.

    Al trabajar con AWS DataStore tienes que lidiar con operaciones async/await. Actualizar una lista de elementos cuando el orden de ejecución de las operaciones asincrónicas no es obligatorio es viable hacerlo con Promise.all() y map. Actualizar elementos relacionados, donde el resultado de la promesa del elemento anterior es necesario como entrada para el siguiente elemento, se puede lograr con la declaración for await...of.