{
  "schema_version": 1,
  "generated_at": "2026-11-09T16:08:36.899Z",
  "license": "CC-BY-SA-4.0",
  "attribution": "Letra Chica (https://cloud-rosetta-stone.vercel.app), CC BY-SA 4.0",
  "patterns": [
    {
      "id": "async-worker",
      "status": "published",
      "category": "async-event",
      "title": {
        "es": "Worker asíncrono con escala a cero y dead-letter",
        "en": "Scale-to-zero async worker with dead-letter"
      },
      "summary": {
        "es": "Un worker procesa mensajes de una cola, escala según el largo de la cola (hasta cero), guarda resultados en object storage y aparta los mensajes que fallan.",
        "en": "A worker processes messages from a queue, scales on queue length (down to zero), stores results in object storage and sets aside failing messages."
      },
      "problem": {
        "es": "Tenés trabajo en background con carga irregular (procesamiento de archivos, envíos, integraciones). Querés pagar cerca de cero cuando no hay mensajes, escalar solo cuando la cola crece, no perder mensajes que fallan y no usar claves ni connection strings: el worker se autentica con su identidad contra la cola y el storage.",
        "en": "You have background work with irregular load (file processing, notifications, integrations). You want to pay close to zero when there are no messages, scale only when the queue grows, not lose failing messages and use no keys or connection strings: the worker authenticates with its identity against the queue and storage."
      },
      "roles": [
        {
          "id": "producer",
          "kind": "actor",
          "label": {
            "es": "Productor",
            "en": "Producer"
          }
        },
        {
          "id": "queue",
          "kind": "capability",
          "label": {
            "es": "Cola",
            "en": "Queue"
          },
          "capability": "message-queue"
        },
        {
          "id": "worker",
          "kind": "capability",
          "label": {
            "es": "Worker",
            "en": "Worker"
          },
          "capability": "event-driven-compute"
        },
        {
          "id": "identity",
          "kind": "capability",
          "label": {
            "es": "Identidad sin secretos",
            "en": "Secretless identity"
          },
          "capability": "workload-identity"
        },
        {
          "id": "results",
          "kind": "capability",
          "label": {
            "es": "Resultados",
            "en": "Results"
          },
          "capability": "object-storage"
        },
        {
          "id": "dead-letter",
          "kind": "capability",
          "label": {
            "es": "Mensajes fallidos",
            "en": "Failed messages"
          },
          "capability": "dead-letter-queue"
        }
      ],
      "edges": [
        {
          "from": "producer",
          "to": "queue",
          "label": {
            "es": "envía",
            "en": "sends"
          }
        },
        {
          "from": "queue",
          "to": "worker",
          "label": {
            "es": "dispara / escala",
            "en": "triggers / scales"
          }
        },
        {
          "from": "worker",
          "to": "identity",
          "label": {
            "es": "token",
            "en": "token"
          }
        },
        {
          "from": "worker",
          "to": "results",
          "label": {
            "es": "escribe",
            "en": "writes"
          }
        },
        {
          "from": "queue",
          "to": "dead-letter",
          "label": {
            "es": "tras N intentos",
            "en": "after N attempts"
          }
        }
      ],
      "paths": [
        {
          "id": "message",
          "label": {
            "es": "Mensaje",
            "en": "Message"
          },
          "roles": [
            "producer",
            "queue",
            "worker",
            "results"
          ]
        },
        {
          "id": "token",
          "label": {
            "es": "Token",
            "en": "Token"
          },
          "roles": [
            "worker",
            "identity"
          ]
        },
        {
          "id": "failure",
          "label": {
            "es": "Falla",
            "en": "Failure"
          },
          "roles": [
            "queue",
            "dead-letter"
          ]
        }
      ],
      "tags": [
        "messaging",
        "serverless",
        "scale-to-zero",
        "passwordless"
      ],
      "legacy_unreviewed": true,
      "implementations": {
        "azure": {
          "provider": "azure",
          "bindings": {
            "queue": {
              "service": "Azure Service Bus (Standard) — queue",
              "config": {
                "es": "local_auth_enabled = false (solo Entra ID)",
                "en": "local_auth_enabled = false (Entra ID only)"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "Peek-lock es el mismo modelo que el visibility timeout (at-least-once). La diferencia grande: red privada exige el tier Premium.",
                  "en": "Peek-lock is the same model as the visibility timeout (at-least-once). The big difference: private networking requires the Premium tier."
                }
              }
            },
            "worker": {
              "service": "Container Apps Job (event-driven)",
              "config": {
                "es": "Scaler KEDA azure-servicebus con identidad administrada",
                "en": "KEDA azure-servicebus scaler with managed identity"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": "User-assigned Managed Identity",
              "config": "Service Bus Data Receiver + Storage Blob Data Contributor",
              "equivalence": "full"
            },
            "results": {
              "service": "Storage Account + container",
              "config": {
                "es": "Shared Key deshabilitado",
                "en": "Shared Key disabled"
              },
              "equivalence": "full"
            },
            "dead-letter": {
              "service": {
                "es": "Subcola $DeadLetterQueue",
                "en": "$DeadLetterQueue subqueue"
              },
              "config": {
                "es": "max_delivery_count en la cola",
                "en": "max_delivery_count on the queue"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "sb-private-endpoint-premium",
              "severity": "cost",
              "title": {
                "es": "Red privada en Service Bus exige Premium, que tiene costo fijo alto",
                "en": "Private networking on Service Bus requires Premium, which has a high fixed cost"
              },
              "body": {
                "es": "Private Endpoints, reglas de VNet y CMK requieren namespaces Premium, que se cobran por messaging unit. Standard admite reglas de firewall por IP. Este patrón usa Standard con autenticación solo Entra ID: si el requisito es red privada, hay que pasar a Premium. En AWS, SQS se conecta por un interface VPC endpoint.",
                "en": "Private Endpoints, VNet rules and CMK require Premium-tier namespaces, which are billed per messaging unit. Standard supports IP firewall rules. This pattern uses Standard with Entra ID-only auth: if the requirement is private networking, you have to move to Premium. In AWS, SQS connects through an interface VPC endpoint."
              },
              "sources": [
                "https://learn.microsoft.com/azure/service-bus-messaging/private-link-service",
                "https://learn.microsoft.com/azure/service-bus-messaging/secure-service-bus",
                "https://azure.microsoft.com/pricing/details/service-bus/",
                "https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-internetwork-traffic-privacy.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "queue"
              ],
              "exams": [
                "AZ-305",
                "AZ-700",
                "SC-500"
              ]
            },
            {
              "id": "peek-lock-is-at-least-once",
              "severity": "reliability",
              "title": {
                "es": "Peek-lock es at-least-once, no exactly-once",
                "en": "Peek-lock is at-least-once, not exactly-once"
              },
              "body": {
                "es": "Si el worker muere después de procesar pero antes de completar el mensaje, el lock expira y el mensaje se reentrega. Es el mismo modelo que el visibility timeout de SQS. La duplicate detection de Service Bus deduplica envíos repetidos, no procesamientos: el worker tiene que ser idempotente.",
                "en": "If the worker dies after processing but before completing the message, the lock expires and the message is redelivered. It's the same model as SQS's visibility timeout. Service Bus duplicate detection dedupes repeated sends, not processing: the worker must be idempotent."
              },
              "sources": [
                "https://learn.microsoft.com/azure/service-bus-messaging/service-bus-message-loss-and-duplicates"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "queue",
                "worker"
              ],
              "exams": [
                "AI-200",
                "AZ-305"
              ]
            },
            {
              "id": "dlq-fills-quota",
              "severity": "reliability",
              "title": {
                "es": "La dead-letter queue ocupa la cuota de la cola si nadie la vacía",
                "en": "The dead-letter queue uses the queue's quota if nobody drains it"
              },
              "body": {
                "es": "Tras max_delivery_count entregas (10 por default) el mensaje va a la subcola $DeadLetterQueue, que cuenta para el tamaño de la entidad. Si se acumula, la cola principal empieza a rechazar envíos con QuotaExceeded. Necesitás alertas sobre el conteo de dead-letter y un proceso para reprocesar o descartar.",
                "en": "After max_delivery_count deliveries (10 by default) the message goes to the $DeadLetterQueue subqueue, which counts towards the entity size. If it piles up, the main queue starts rejecting sends with QuotaExceeded. You need alerts on the dead-letter count and a process to reprocess or discard."
              },
              "sources": [
                "https://learn.microsoft.com/azure/service-bus-messaging/service-bus-dead-letter-queues",
                "https://learn.microsoft.com/azure/service-bus-messaging/service-bus-messaging-exceptions-latest"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "dead-letter",
                "queue"
              ],
              "exams": [
                "AI-200"
              ]
            },
            {
              "id": "keda-scaler-identity",
              "severity": "iam",
              "title": {
                "es": "La regla de escalado se autentica con la propiedad identity, no con auth",
                "en": "The scale rule authenticates with the identity property, not auth"
              },
              "body": {
                "es": "Para que la regla de escalado use una identidad administrada, se configura la propiedad identity en lugar de auth. El portal no lo soporta: hacelo por IaC o CLI.",
                "en": "For the scale rule to use a managed identity, set the identity property instead of auth. The portal doesn't support it: use IaC or CLI."
              },
              "sources": [
                "https://learn.microsoft.com/azure/container-apps/scale-app",
                "https://learn.microsoft.com/azure/container-apps/managed-identity"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "worker",
                "identity"
              ],
              "exams": [
                "AI-200"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "partial",
            "fixed_cost_drivers": [
              {
                "es": "Service Bus Standard (cargo base mensual)",
                "en": "Service Bus Standard (monthly base charge)"
              }
            ],
            "notes": {
              "es": "El job no consume nada sin mensajes y el storage se paga por uso. El único fijo es el base de Service Bus Standard, chico comparado con Premium (ver gotcha).",
              "en": "The job consumes nothing without messages and storage is pay-per-use. The only fixed cost is the Service Bus Standard base charge, small compared to Premium (see gotcha)."
            }
          }
        },
        "aws": {
          "provider": "aws",
          "bindings": {
            "queue": {
              "service": "Amazon SQS (Standard)",
              "config": {
                "es": "SSE administrado por SQS",
                "en": "SQS-managed SSE"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "El visibility timeout cumple el papel del lock de peek-lock, y tiene que ser al menos 6 veces el timeout de la Lambda.",
                  "en": "The visibility timeout plays the role of the peek-lock lock, and it must be at least 6 times the Lambda timeout."
                }
              }
            },
            "worker": {
              "service": "AWS Lambda",
              "config": {
                "es": "Event source mapping con ReportBatchItemFailures",
                "en": "Event source mapping with ReportBatchItemFailures"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": {
                "es": "IAM Role (execution role de Lambda)",
                "en": "IAM Role (Lambda execution role)"
              },
              "config": {
                "es": "sqs:ReceiveMessage/DeleteMessage + s3:PutObject acotado",
                "en": "sqs:ReceiveMessage/DeleteMessage + scoped s3:PutObject"
              },
              "equivalence": "full"
            },
            "results": {
              "service": "S3 bucket",
              "config": "Block Public Access + BucketOwnerEnforced",
              "equivalence": "full"
            },
            "dead-letter": {
              "service": {
                "es": "Cola SQS separada",
                "en": "Separate SQS queue"
              },
              "config": {
                "es": "redrive_policy con maxReceiveCount",
                "en": "redrive_policy with maxReceiveCount"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "No es una subcola: es otra cola, y en Standard el mensaje vence según cuándo entró a la cola original. Dale más retención que al origen.",
                  "en": "It isn't a subqueue: it's another queue, and in Standard the message expires based on when it entered the original queue. Give it longer retention than the source."
                }
              }
            }
          },
          "gotchas": [
            {
              "id": "visibility-timeout-6x",
              "severity": "reliability",
              "title": {
                "es": "El visibility timeout de la cola tiene que ser al menos 6 veces el timeout de la Lambda",
                "en": "The queue's visibility timeout must be at least 6 times the Lambda timeout"
              },
              "body": {
                "es": "Si la función tarda más que el visibility timeout, el mensaje vuelve a ser visible y otra invocación lo toma mientras el primero sigue procesando: duplicados garantizados. AWS recomienda visibility timeout ≥ 6× el timeout de la función. El módulo lo calcula y lo valida.",
                "en": "If the function takes longer than the visibility timeout, the message becomes visible again and another invocation picks it up while the first is still processing: guaranteed duplicates. AWS recommends a visibility timeout ≥ 6× the function timeout. The module computes and validates it."
              },
              "sources": [
                "https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html",
                "https://docs.aws.amazon.com/lambda/latest/operatorguide/sqs-retries.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "queue",
                "worker"
              ],
              "exams": [
                "DVA-C02",
                "SAA-C03"
              ]
            },
            {
              "id": "partial-batch-failures",
              "severity": "reliability",
              "title": {
                "es": "Sin ReportBatchItemFailures, un mensaje fallido reintenta todo el lote",
                "en": "Without ReportBatchItemFailures, one failed message retries the whole batch"
              },
              "body": {
                "es": "Por default, si la función falla, todo el lote vuelve a la cola, incluidos los mensajes ya procesados. Con function_response_types = [\"ReportBatchItemFailures\"] la función devuelve solo los IDs que fallaron. En Azure y GCP el ack es por mensaje, así que este problema no existe de la misma forma.",
                "en": "By default, if the function fails the whole batch goes back to the queue, including already processed messages. With function_response_types = [\"ReportBatchItemFailures\"] the function returns only the failed IDs. In Azure and GCP acks are per message, so this problem doesn't exist in the same way."
              },
              "sources": [
                "https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "worker"
              ],
              "exams": [
                "DVA-C02"
              ]
            },
            {
              "id": "dlq-retention-original-timestamp",
              "severity": "reliability",
              "title": {
                "es": "En colas Standard, el mensaje expira en la DLQ según cuándo entró a la cola original",
                "en": "In Standard queues, the message expires in the DLQ based on when it entered the original queue"
              },
              "body": {
                "es": "Al mover un mensaje a la DLQ, SQS conserva el timestamp de encolado original (en FIFO se resetea). Si la DLQ tiene la misma retención que la cola de origen, los mensajes pueden desaparecer de la DLQ casi enseguida. Configurá la retención de la DLQ más larga que la de origen (el módulo usa el máximo, 14 días).",
                "en": "When moving a message to the DLQ, SQS keeps the original enqueue timestamp (in FIFO it resets). If the DLQ has the same retention as the source queue, messages can vanish from the DLQ almost immediately. Set the DLQ retention longer than the source (the module uses the maximum, 14 days)."
              },
              "sources": [
                "https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html",
                "https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/setting-up-dead-letter-queue-retention.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "dead-letter"
              ],
              "exams": [
                "SAA-C03",
                "DVA-C02"
              ]
            },
            {
              "id": "max-concurrency-protects-downstream",
              "severity": "operations",
              "title": {
                "es": "Limitá la concurrencia del event source mapping, no solo la de la función",
                "en": "Limit the event source mapping's concurrency, not just the function's"
              },
              "body": {
                "es": "scaling_config.maximum_concurrency limita cuántas instancias concurrentes de la función puede invocar la cola. Es un setting del event source mapping, independiente del reserved concurrency de la función y sin cargo, y evita que una cola consuma toda la concurrencia reservada o la cuota de la cuenta. Ojo: el reserved concurrency tiene que ser mayor o igual que la suma de los máximos de todos los event sources de la función; si no, Lambda puede hacer throttling de tus mensajes.",
                "en": "scaling_config.maximum_concurrency limits how many concurrent instances of the function the queue can invoke. It's an event source mapping setting, independent of the function's reserved concurrency and free of charge, and it keeps one queue from using all of the reserved concurrency or the account's concurrency quota. Note: the reserved concurrency must be greater than or equal to the sum of the maximums of all the function's event sources; otherwise Lambda might throttle your messages."
              },
              "sources": [
                "https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-scaling.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "worker"
              ],
              "exams": [
                "DVA-C02",
                "SAA-C03"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Cero costo fijo: SQS, Lambda y S3 se cobran por uso. Ojo con el long polling del event source mapping: las requests de polling a SQS se cobran aunque la cola esté vacía (son pocas, pero no son cero).",
              "en": "Zero fixed cost: SQS, Lambda and S3 are pay-per-use. Note the event source mapping's long polling: SQS polling requests are billed even when the queue is empty (few, but not zero)."
            }
          }
        },
        "gcp": {
          "provider": "gcp",
          "bindings": {
            "queue": {
              "service": "Pub/Sub topic + push subscription",
              "config": {
                "es": "Push autenticado con OIDC",
                "en": "OIDC-authenticated push"
              },
              "notes": {
                "es": "Pub/Sub es publicación/suscripción: el topic no guarda mensajes, se retienen por suscripción.",
                "en": "Pub/Sub is publish/subscribe: the topic doesn't store messages, they're retained per subscription."
              },
              "equivalence": "partial",
              "from": {
                "aws": {
                  "es": "No es una cola: los mensajes viven en la suscripción. Con push, el ack es la respuesta HTTP y tiene que llegar dentro del ack deadline.",
                  "en": "It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline."
                },
                "azure": {
                  "es": "No es una cola: los mensajes viven en la suscripción. Con push, el ack es la respuesta HTTP y tiene que llegar dentro del ack deadline.",
                  "en": "It isn't a queue: messages live in the subscription. With push, the ack is the HTTP response and must arrive within the ack deadline."
                }
              }
            },
            "worker": {
              "service": "Cloud Run (service)",
              "config": {
                "es": "Ingress interno, invocado por Pub/Sub",
                "en": "Internal ingress, invoked by Pub/Sub"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": {
                "es": "Service Accounts (worker + invoker de push)",
                "en": "Service Accounts (worker + push invoker)"
              },
              "config": {
                "es": "roles/run.invoker para la SA de push",
                "en": "roles/run.invoker for the push SA"
              },
              "equivalence": "full"
            },
            "results": {
              "service": "Cloud Storage bucket",
              "config": "UBLA + public access prevention",
              "equivalence": "full"
            },
            "dead-letter": {
              "service": {
                "es": "Dead-letter topic + suscripción pull",
                "en": "Dead-letter topic + pull subscription"
              },
              "config": {
                "es": "max_delivery_attempts + permisos del service agent",
                "en": "max_delivery_attempts + service agent permissions"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "dlq-needs-service-agent-roles",
              "severity": "iam",
              "title": {
                "es": "El dead-letter no funciona sin permisos para el service agent de Pub/Sub",
                "en": "Dead-lettering doesn't work without permissions for the Pub/Sub service agent"
              },
              "body": {
                "es": "Quien reenvía al dead-letter topic es service-<número>@gcp-sa-pubsub.iam.gserviceaccount.com, y necesita publisher en el dead-letter topic y subscriber en la suscripción de origen. Sin esos roles la política está configurada pero los mensajes nunca se mueven. El módulo otorga ambos.",
                "en": "The forwarder to the dead-letter topic is service-<number>@gcp-sa-pubsub.iam.gserviceaccount.com, and it needs publisher on the dead-letter topic and subscriber on the source subscription. Without those roles the policy is configured but messages never move. The module grants both."
              },
              "sources": [
                "https://docs.cloud.google.com/pubsub/docs/handling-failures"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "dead-letter",
                "identity"
              ],
              "exams": [
                "PCD",
                "PDE"
              ]
            },
            {
              "id": "dead-letter-topic-needs-subscription",
              "severity": "reliability",
              "title": {
                "es": "Un dead-letter topic sin suscripción descarta los mensajes",
                "en": "A dead-letter topic without a subscription drops messages"
              },
              "body": {
                "es": "En Pub/Sub un topic no guarda mensajes por sí mismo: se retienen por suscripción. Si el dead-letter topic no tiene una suscripción creada antes de que lleguen los mensajes, se pierden. El mínimo de intentos antes de reenviar es 5 (máximo 100).",
                "en": "In Pub/Sub a topic doesn't store messages by itself: they're retained per subscription. If the dead-letter topic has no subscription created before messages arrive, they're lost. The minimum delivery attempts before forwarding is 5 (maximum 100)."
              },
              "sources": [
                "https://docs.cloud.google.com/pubsub/docs/handling-failures",
                "https://docs.cloud.google.com/pubsub/docs/dead-letter-topics"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "dead-letter"
              ],
              "exams": [
                "PDE",
                "PCD"
              ]
            },
            {
              "id": "push-ack-deadline",
              "severity": "reliability",
              "title": {
                "es": "En push, el ack es la respuesta HTTP y tiene que llegar dentro del ack deadline",
                "en": "With push, the ack is the HTTP response and must arrive within the ack deadline"
              },
              "body": {
                "es": "Pub/Sub considera procesado el mensaje cuando el endpoint responde con 102, 200, 201, 202 o 204; cualquier otro código es un nack y Pub/Sub reenvía el mensaje. Lo mismo pasa si el worker tarda más que el ack deadline: Pub/Sub lo reentrega mientras sigue corriendo.",
                "en": "Pub/Sub considers the message processed when the endpoint responds with 102, 200, 201, 202 or 204; any other code is a nack and Pub/Sub resends the message. The same happens if the worker takes longer than the ack deadline: Pub/Sub redelivers it while it's still running."
              },
              "sources": [
                "https://docs.cloud.google.com/pubsub/docs/push",
                "https://docs.cloud.google.com/pubsub/docs/lease-management"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "queue",
                "worker"
              ],
              "exams": [
                "PCD",
                "PDE"
              ]
            },
            {
              "id": "exactly-once-pull-only",
              "severity": "reliability",
              "title": {
                "es": "Pub/Sub ofrece exactly-once delivery, pero solo en suscripciones pull",
                "en": "Pub/Sub offers exactly-once delivery, but only on pull subscriptions"
              },
              "body": {
                "es": "Pub/Sub soporta exactly-once delivery solo con suscripciones pull: push (lo que dispara a Cloud Run) no lo soporta, porque el cliente no sabe si Pub/Sub recibió la respuesta y la procesó.",
                "en": "Pub/Sub supports exactly-once delivery only with pull subscriptions: push (what triggers Cloud Run) doesn't support it, because the client doesn't know whether Pub/Sub received the response and processed it."
              },
              "sources": [
                "https://docs.cloud.google.com/pubsub/docs/exactly-once-delivery"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "queue"
              ],
              "exams": [
                "PDE",
                "PCD"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Cero costo fijo: Pub/Sub se cobra por volumen, Cloud Run escala a cero y Cloud Storage por uso. Push además evita el polling: no hay requests facturables con la cola vacía.",
              "en": "Zero fixed cost: Pub/Sub is billed by volume, Cloud Run scales to zero and Cloud Storage is pay-per-use. Push also avoids polling: there are no billable requests while the queue is empty."
            }
          }
        }
      },
      "terraform": {
        "azure": {
          "providers": {
            "azurerm": "~> 5.8"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "aws": {
          "providers": {
            "aws": "~> 6.67"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "gcp": {
          "providers": {
            "google": "~> 8.6"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        }
      }
    },
    {
      "id": "ci-oidc-federation",
      "status": "published",
      "category": "identity",
      "title": {
        "es": "Despliegue desde CI sin secretos (federación OIDC)",
        "en": "Secretless CI/CD deployments (OIDC federation)"
      },
      "summary": {
        "es": "Un job de GitHub Actions despliega con Terraform en la nube usando su token OIDC, sin client secrets ni access keys guardados en el repositorio.",
        "en": "A GitHub Actions job deploys to the cloud with Terraform using its OIDC token, with no client secrets or access keys stored in the repository."
      },
      "problem": {
        "es": "El pipeline necesita credenciales para desplegar, y una clave guardada como secret del repo es la credencial de larga duración más expuesta que tiene una organización: no vence sola, se copia entre repos y nadie sabe quién la usó. Los requisitos son: cero secretos estáticos, confianza acotada a un repositorio y a un environment concreto (no a cualquier rama ni a cualquier repo de la organización), permisos mínimos sobre un scope y tokens que duran minutos.",
        "en": "The pipeline needs credentials to deploy, and a key stored as a repo secret is the most exposed long-lived credential an organization has: it never expires on its own, gets copied across repos, and nobody knows who used it. Requirements: zero static secrets, trust scoped to one repository and one specific environment (not any branch or any repo in the org), least-privilege permissions on a scope, and tokens that last minutes."
      },
      "roles": [
        {
          "id": "pipeline",
          "kind": "actor",
          "label": {
            "es": "Job de GitHub Actions",
            "en": "GitHub Actions job"
          }
        },
        {
          "id": "federation",
          "kind": "capability",
          "label": {
            "es": "Confianza federada",
            "en": "Federated trust"
          },
          "capability": "external-identity-federation"
        },
        {
          "id": "deploy-identity",
          "kind": "capability",
          "label": {
            "es": "Identidad de despliegue",
            "en": "Deployment identity"
          },
          "capability": "workload-identity"
        },
        {
          "id": "permissions",
          "kind": "capability",
          "label": {
            "es": "Permisos mínimos",
            "en": "Least-privilege permissions"
          },
          "capability": "access-control"
        },
        {
          "id": "target",
          "kind": "actor",
          "label": {
            "es": "Recursos destino",
            "en": "Target resources"
          }
        }
      ],
      "edges": [
        {
          "from": "pipeline",
          "to": "federation",
          "label": {
            "es": "token OIDC (aud, sub)",
            "en": "OIDC token (aud, sub)"
          }
        },
        {
          "from": "federation",
          "to": "deploy-identity",
          "label": {
            "es": "token de corta duración",
            "en": "short-lived token"
          }
        },
        {
          "from": "permissions",
          "to": "deploy-identity",
          "label": {
            "es": "rol sobre un scope",
            "en": "role on a scope"
          }
        },
        {
          "from": "deploy-identity",
          "to": "target",
          "label": {
            "es": "terraform apply",
            "en": "terraform apply"
          }
        }
      ],
      "paths": [
        {
          "id": "token",
          "label": {
            "es": "Token",
            "en": "Token"
          },
          "roles": [
            "pipeline",
            "federation",
            "deploy-identity"
          ]
        },
        {
          "id": "deploy",
          "label": {
            "es": "Despliegue",
            "en": "Deploy"
          },
          "roles": [
            "deploy-identity",
            "target"
          ]
        },
        {
          "id": "permissions",
          "label": {
            "es": "Permisos",
            "en": "Permissions"
          },
          "roles": [
            "permissions",
            "deploy-identity"
          ]
        }
      ],
      "tags": [
        "identity",
        "ci-cd",
        "zero-trust",
        "github-actions"
      ],
      "legacy_unreviewed": true,
      "implementations": {
        "azure": {
          "provider": "azure",
          "bindings": {
            "federation": {
              "service": {
                "es": "Federated identity credential (workload identity federation)",
                "en": "Federated identity credential (workload identity federation)"
              },
              "config": {
                "es": "Uno por environment: issuer de GitHub + subject exacto + audience api://AzureADTokenExchange",
                "en": "One per environment: GitHub issuer + exact subject + audience api://AzureADTokenExchange"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "No hay trust policy con comodines: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.",
                  "en": "There's no trust policy with wildcards: each environment, branch or tag is a credential with an exact subject, up to 20 per identity."
                },
                "gcp": {
                  "es": "No hay condición CEL: cada environment, rama o tag es una credencial con subject exacto, con un máximo de 20 por identidad.",
                  "en": "There's no CEL condition: each environment, branch or tag is a credential with an exact subject, up to 20 per identity."
                }
              }
            },
            "deploy-identity": {
              "service": "User-assigned Managed Identity",
              "config": {
                "es": "Sin secretos ni certificados; el workflow solo conoce client-id y tenant-id",
                "en": "No secrets or certificates; the workflow only knows client-id and tenant-id"
              },
              "equivalence": "full"
            },
            "permissions": {
              "service": "Azure RBAC",
              "config": {
                "es": "Un role assignment sobre el resource group destino",
                "en": "One role assignment on the target resource group"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "fic-exact-match-max-20",
              "severity": "iam",
              "title": {
                "es": "El subject tiene que coincidir exacto, sin comodines, y hay un máximo de 20 credenciales por identidad",
                "en": "The subject must match exactly, with no wildcards, and there are at most 20 credentials per identity"
              },
              "body": {
                "es": "En Azure la confianza no es una policy: cada federated identity credential es un par issuer + subject que tiene que coincidir carácter por carácter con el sub del token. No hay comodines, así que necesitás una credencial por environment, rama o tag, con un tope de 20 por identidad. En AWS o GCP lo resolvés con StringLike o una condición CEL. Ojo: si cargás mal el subject, la credencial se crea sin error y la falla recién aparece en el login.",
                "en": "In Azure trust isn't a policy: each federated identity credential is an issuer + subject pair that must match the token's sub character by character. There are no wildcards, so you need one credential per environment, branch or tag, capped at 20 per identity. In AWS or GCP you'd use StringLike or a CEL condition. Watch out: a wrong subject is created without error, and the failure only shows up at login."
              },
              "sources": [
                "https://learn.microsoft.com/entra/workload-id/workload-identity-federation-considerations"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "AZ-400",
                "SC-300"
              ]
            },
            {
              "id": "fic-propagation-aadsts70021",
              "severity": "reliability",
              "title": {
                "es": "Una credencial recién creada puede fallar con AADSTS70021 durante unos minutos",
                "en": "A freshly created credential can fail with AADSTS70021 for a few minutes"
              },
              "body": {
                "es": "La credencial tarda en replicarse en la región. Si el mismo pipeline la crea y enseguida la usa, el token exchange puede fallar con \"No matching federated identity record found for presented assertion\" aunque el subject esté bien. Microsoft recomienda esperar y reintentar. Si el bootstrap y el primer deploy van en la misma corrida, separalos o agregá un reintento con espera.",
                "en": "The credential takes time to replicate across the region. If the same pipeline creates it and uses it right away, the token exchange can fail with \"No matching federated identity record found for presented assertion\" even though the subject is right. Microsoft recommends waiting and retrying. If bootstrap and the first deploy run together, split them or add a retry with backoff."
              },
              "sources": [
                "https://learn.microsoft.com/entra/workload-id/workload-identity-federation-considerations"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "AZ-400"
              ]
            },
            {
              "id": "fic-concurrent-writes-409",
              "severity": "operations",
              "title": {
                "es": "Crear varias credenciales en paralelo sobre la misma identidad da 409",
                "en": "Creating several credentials in parallel on the same identity returns 409"
              },
              "body": {
                "es": "Las escrituras concurrentes de federated identity credentials bajo una misma user-assigned identity se rechazan con 409 Conflict. El provider azurerm las crea en serie desde la 3.40, así que un for_each no rompe. En ARM o Bicep, en cambio, un loop las crea en paralelo: usá el modo serial (batchSize 1) o encadená dependsOn.",
                "en": "Concurrent writes of federated identity credentials under the same user-assigned identity are rejected with 409 Conflict. The azurerm provider creates them serially since 3.40, so a for_each won't break. In ARM or Bicep, though, a loop creates them in parallel: use serial mode (batchSize 1) or chain dependsOn."
              },
              "sources": [
                "https://learn.microsoft.com/entra/workload-id/workload-identity-federation-considerations"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "AZ-400"
              ]
            },
            {
              "id": "contributor-cannot-assign-roles",
              "severity": "iam",
              "title": {
                "es": "Con Contributor el pipeline no puede crear role assignments",
                "en": "With Contributor the pipeline can't create role assignments"
              },
              "body": {
                "es": "Contributor no permite asignar roles en Azure RBAC (excluye Microsoft.Authorization/*/Write). Si tu Terraform asigna roles (por ejemplo, la identidad de una app sobre un Key Vault), Contributor no alcanza. La salida rápida es darle Owner o User Access Administrator al pipeline, y es justo lo que no querés. La salida correcta es Role Based Access Control Administrator con una condición que limite qué roles puede asignar (y que excluya los roles privilegiados).",
                "en": "Contributor does not allow assigning roles in Azure RBAC (it excludes Microsoft.Authorization/*/Write). If your Terraform assigns roles (say, an app's identity on a Key Vault), Contributor is not enough. The quick fix is giving the pipeline Owner or User Access Administrator, which is exactly what you don't want. The right fix is Role Based Access Control Administrator with a condition limiting which roles it can assign (and excluding privileged ones)."
              },
              "sources": [
                "https://learn.microsoft.com/azure/role-based-access-control/delegate-role-assignments-portal",
                "https://learn.microsoft.com/azure/role-based-access-control/delegate-role-assignments-examples",
                "https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/privileged"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "permissions"
              ],
              "exams": [
                "AZ-104",
                "SC-500",
                "AZ-400"
              ]
            },
            {
              "id": "github-sub-format-changed",
              "severity": "iam",
              "title": {
                "es": "Desde el 15/07/2026, los repos nuevos, renombrados o transferidos usan otro formato de sub",
                "en": "Since 2026-07-15, new, renamed or transferred repos use a different sub format"
              },
              "body": {
                "es": "GitHub agrega los IDs numéricos al claim: repo:org@123/repo@456:environment:prod en lugar de repo:org/repo:environment:prod. Así un nombre reciclado no hereda la confianza. Los repos anteriores siguen con el formato viejo salvo que lo activen. Si renombrás un repo, el sub cambia y todas las credenciales exactas dejan de matchear. El módulo arma el subject con IDs cuando se los pasás.",
                "en": "GitHub adds the numeric IDs to the claim: repo:org@123/repo@456:environment:prod instead of repo:org/repo:environment:prod, so a recycled name doesn't inherit the trust. Older repos keep the old format unless they opt in. If you rename a repo, the sub changes and every exact credential stops matching. The module builds the subject with IDs when you pass them."
              },
              "sources": [
                "https://docs.github.com/en/actions/reference/security/oidc",
                "https://learn.microsoft.com/azure/databricks/dev-tools/auth/provider-github"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "AZ-400"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Managed identities, federated identity credentials y role assignments no tienen costo. Pagás solo los recursos que despliega el pipeline.",
              "en": "Managed identities, federated identity credentials and role assignments are free. You only pay for what the pipeline deploys."
            }
          }
        },
        "aws": {
          "provider": "aws",
          "bindings": {
            "federation": {
              "service": "IAM OIDC identity provider",
              "config": {
                "es": "Uno por cuenta para token.actions.githubusercontent.com; client_id_list = sts.amazonaws.com",
                "en": "One per account for token.actions.githubusercontent.com; client_id_list = sts.amazonaws.com"
              },
              "equivalence": "full"
            },
            "deploy-identity": {
              "service": "IAM Role",
              "config": {
                "es": "Trust policy: AssumeRoleWithWebIdentity con aud y sub exactos (StringEquals)",
                "en": "Trust policy: AssumeRoleWithWebIdentity with exact aud and sub (StringEquals)"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "El rol junta identidad, confianza (trust policy) y permisos; en Azure son tres objetos separados. La trust policy admite StringLike, a diferencia del match exacto de un federated credential.",
                  "en": "The role bundles identity, trust (trust policy) and permissions; in Azure those are three separate objects. The trust policy allows StringLike, unlike a federated credential's exact match."
                }
              }
            },
            "permissions": {
              "service": {
                "es": "IAM policies adjuntas al rol",
                "en": "IAM policies attached to the role"
              },
              "config": {
                "es": "Policies administradas acotadas; nunca AdministratorAccess",
                "en": "Scoped managed policies; never AdministratorAccess"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "sub-condition-is-the-boundary",
              "severity": "security",
              "title": {
                "es": "IAM exige una condición sobre sub, pero acepta una que igual abre la puerta",
                "en": "IAM requires a sub condition, but accepts one that still leaves the door open"
              },
              "body": {
                "es": "El issuer de GitHub es el mismo para todas las organizaciones: sin acotar el sub, cualquier repositorio de GitHub podría asumir tu rol. Por eso IAM rechaza crear o actualizar una trust policy hacia ese provider si falta la condición sobre sub (o job_workflow_ref) o si es solo un comodín. Pero \"repo:*\" o \"repo:mi-org/*\" pasan la validación. El límite real lo ponés vos: este módulo usa StringEquals con un sub por environment.",
                "en": "GitHub's issuer is the same for every organization: without scoping sub, any GitHub repository could assume your role. That's why IAM rejects creating or updating a trust policy for that provider when the sub (or job_workflow_ref) condition is missing or is just a wildcard. But \"repo:*\" or \"repo:my-org/*\" pass validation. The real boundary is yours to set: this module uses StringEquals with one sub per environment."
              },
              "sources": [
                "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html",
                "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc_secure-by-default.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation",
                "deploy-identity"
              ],
              "exams": [
                "SCS-C03",
                "SAA-C03"
              ]
            },
            {
              "id": "environment-changes-the-sub",
              "severity": "iam",
              "title": {
                "es": "Si el job declara un environment, el sub deja de tener la rama",
                "en": "If the job declares an environment, the sub no longer contains the branch"
              },
              "body": {
                "es": "GitHub incluye el environment en el sub cuando el job lo referencia; pull_request y la ref solo entran si el job no referencia un environment. Un job en main con environment production presenta repo:org/repo:environment:production, así que una trust policy que espera repo:org/repo:ref:refs/heads/main rechaza el AssumeRoleWithWebIdentity.",
                "en": "GitHub includes the environment in the sub when the job references one; pull_request and the ref only appear if the job doesn't reference an environment. A job on main with environment production presents repo:org/repo:environment:production, so a trust policy expecting repo:org/repo:ref:refs/heads/main rejects AssumeRoleWithWebIdentity."
              },
              "sources": [
                "https://docs.github.com/en/actions/reference/security/oidc"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "SCS-C03"
              ]
            },
            {
              "id": "one-oidc-provider-per-account",
              "severity": "operations",
              "title": {
                "es": "Hay un solo OIDC provider por URL y por cuenta",
                "en": "There's only one OIDC provider per URL per account"
              },
              "body": {
                "es": "Crear un segundo provider para token.actions.githubusercontent.com en la misma cuenta falla: IAM no permite registrar la misma URL dos veces en una cuenta. Si cada repo trae su propio stack de Terraform con el provider adentro, el segundo apply rompe. Crealo una vez (en el stack de la cuenta) y referencialo con el data source; el módulo lo permite con create_oidc_provider = false.",
                "en": "Creating a second provider for token.actions.githubusercontent.com in the same account fails: IAM doesn't let you register the same URL twice in an account. If each repo ships its own Terraform stack with the provider inside, the second apply breaks. Create it once (in the account stack) and reference it with the data source; the module supports that with create_oidc_provider = false."
              },
              "sources": [
                "https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateOpenIDConnectProvider.html",
                "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "SCS-C03"
              ]
            },
            {
              "id": "thumbprint-not-used",
              "severity": "operations",
              "title": {
                "es": "Para GitHub, IAM no usa el thumbprint para verificar (y no hace falta cargarlo)",
                "en": "For GitHub, IAM doesn't use the thumbprint to verify (and you don't need to set it)"
              },
              "body": {
                "es": "IAM valida el certificado de GitHub contra su librería de CAs raíz confiables, así que thumbprint_list es opcional y, en el provider de Terraform, para IdPs como GitHub se guarda pero no se usa para verificar. Los thumbprints solo entran en juego si el IdP usa un certificado que no firma una de esas CAs, o si AWS no puede obtener el certificado TLS o se requiere TLS v1.3.",
                "en": "IAM validates GitHub's certificate against its library of trusted root CAs, so thumbprint_list is optional and, in the Terraform provider, for IdPs like GitHub it's stored but not used for verification. Thumbprints only come into play if the IdP uses a certificate not signed by one of those CAs, or if AWS can't retrieve the TLS certificate or TLS v1.3 is required."
              },
              "sources": [
                "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html",
                "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "SCS-C03"
              ]
            },
            {
              "id": "session-duration-long-applies",
              "severity": "reliability",
              "title": {
                "es": "La sesión dura 1 hora por default, y encadenar roles la limita a 1 hora sí o sí",
                "en": "The session lasts 1 hour by default, and role chaining caps it at 1 hour regardless"
              },
              "body": {
                "es": "AssumeRoleWithWebIdentity da 3600 s por default; podés pedir hasta el MaxSessionDuration del rol (máximo 12 h), y pedir más que eso hace fallar la llamada. Si desde ese rol asumís otro (role chaining, típico en multi-cuenta), la sesión queda en 1 hora como máximo. Un apply largo que se pasa del límite falla a mitad de camino con el state a medio escribir.",
                "en": "AssumeRoleWithWebIdentity grants 3600 s by default; you can request up to the role's MaxSessionDuration (12 h max), and asking for more makes the call fail. If you assume another role from it (role chaining, common in multi-account), the session is capped at 1 hour. A long apply that runs past the limit fails mid-way with a half-written state."
              },
              "sources": [
                "https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html",
                "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_manage-assume.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "deploy-identity"
              ],
              "exams": [
                "SCS-C03",
                "SAA-C03"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "IAM y STS no tienen costo. Pagás solo los recursos que despliega el pipeline.",
              "en": "IAM and STS are free. You only pay for what the pipeline deploys."
            }
          }
        },
        "gcp": {
          "provider": "gcp",
          "bindings": {
            "federation": {
              "service": "Workload Identity Federation (pool + provider OIDC)",
              "config": {
                "es": "attribute_condition sobre repository_owner_id, repository_id y environment",
                "en": "attribute_condition on repository_owner_id, repository_id and environment"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "La confianza es una condición CEL sobre atributos mapeados, y conviene acotarla por repository_id (inmutable) en vez del nombre.",
                  "en": "Trust is a CEL condition on mapped attributes, and it's best scoped by repository_id (immutable) rather than the name."
                }
              }
            },
            "deploy-identity": {
              "service": "Service Account (impersonada)",
              "config": {
                "es": "roles/iam.workloadIdentityUser solo para el principalSet del repositorio",
                "en": "roles/iam.workloadIdentityUser only for the repository's principalSet"
              },
              "equivalence": "full"
            },
            "permissions": {
              "service": "IAM",
              "config": {
                "es": "Roles de proyecto acotados; nunca owner/editor",
                "en": "Scoped project roles; never owner/editor"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "attribute-condition-mandatory",
              "severity": "security",
              "title": {
                "es": "Sin attribute_condition, el issuer compartido de GitHub deja entrar a cualquier repo",
                "en": "Without attribute_condition, GitHub's shared issuer lets any repo in"
              },
              "body": {
                "es": "GitHub usa un solo issuer para todos sus clientes, así que validar el issuer no alcanza. Google exige una condición que restrinja los tokens a tu organización o repositorio. En Terraform el campo figura como opcional y, sin condición, se acepta toda credencial válida del issuer. Además, todo claim que uses en la condición o en un principalSet tiene que estar mapeado en attribute_mapping.",
                "en": "GitHub uses one issuer for all its customers, so validating the issuer isn't enough. Google requires a condition restricting tokens to your organization or repository. In Terraform the field is listed as optional and, without a condition, every valid credential from the issuer is accepted. Also, every claim you use in the condition or in a principalSet must be mapped in attribute_mapping."
              },
              "sources": [
                "https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines",
                "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool_provider"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "PCSE",
                "PCDOE"
              ]
            },
            {
              "id": "use-immutable-ids",
              "severity": "security",
              "title": {
                "es": "Condicioná sobre los IDs numéricos, no sobre los nombres de org y repo",
                "en": "Condition on numeric IDs, not on org and repo names"
              },
              "body": {
                "es": "Un nombre de organización o de repositorio se puede liberar y volver a registrar: si tu condición dice repository == 'org/repo', el próximo dueño de ese nombre hereda la confianza. Google recomienda atributos inmutables, y GitHub manda repository_id y repository_owner_id en cada token. Es la misma razón por la que GitHub cambió el formato del sub en los repos creados desde el 15/07/2026.",
                "en": "An organization or repository name can be released and registered again: if your condition says repository == 'org/repo', the next owner of that name inherits the trust. Google recommends immutable attributes, and GitHub sends repository_id and repository_owner_id in every token. It's the same reason GitHub changed the sub format for repos created since 2026-07-15."
              },
              "sources": [
                "https://docs.cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation",
                "https://docs.github.com/en/actions/reference/security/oidc"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "PCSE"
              ]
            },
            {
              "id": "pool-soft-delete-30-days",
              "severity": "operations",
              "title": {
                "es": "Un pool borrado bloquea su ID durante unos 30 días",
                "en": "A deleted pool locks its ID for about 30 days"
              },
              "body": {
                "es": "Pools y providers se borran en modo soft delete y se eliminan de verdad a los ~30 días. Mientras tanto no podés crear otro con el mismo ID, así que un terraform destroy seguido de apply con el mismo pool_id falla. Las salidas son restaurarlo (undelete) e importarlo, o usar otro ID. Para entornos efímeros, generá el ID con un sufijo.",
                "en": "Pools and providers are soft-deleted and only permanently removed after ~30 days. Meanwhile you can't create another with the same ID, so a terraform destroy followed by apply with the same pool_id fails. The way out is to restore it (undelete) and import it, or use a different ID. For ephemeral environments, generate the ID with a suffix."
              },
              "sources": [
                "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool",
                "https://docs.cloud.google.com/iam/docs/manage-workload-identity-pools-providers"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation"
              ],
              "exams": [
                "ACE",
                "PCDOE"
              ]
            },
            {
              "id": "direct-access-vs-impersonation",
              "severity": "iam",
              "title": {
                "es": "El acceso directo sin service account no funciona con todos los servicios",
                "en": "Direct access without a service account doesn't work with every service"
              },
              "body": {
                "es": "Se puede dar roles directamente al principal federado, sin service account. Pero algunas APIs tienen limitaciones con identidades federadas y para esas hay que impersonar una service account. Este módulo impersona: funciona con todo y deja un solo lugar donde auditar permisos. Para esa ruta Google pide habilitar cuatro APIs: IAM, Resource Manager, Service Account Credentials y Security Token Service.",
                "en": "You can grant roles directly to the federated principal, without a service account. But some APIs have limitations with federated identities, and for those you have to impersonate a service account. This module impersonates: it works with everything and leaves a single place to audit permissions. For that path Google asks you to enable four APIs: IAM, Resource Manager, Service Account Credentials and Security Token Service."
              },
              "sources": [
                "https://docs.cloud.google.com/iam/docs/federated-identity-supported-services",
                "https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "deploy-identity",
                "permissions"
              ],
              "exams": [
                "PCSE",
                "ACE"
              ]
            },
            {
              "id": "project-number-and-propagation",
              "severity": "reliability",
              "title": {
                "es": "Los principals usan el número de proyecto, y los permisos tardan minutos en propagarse",
                "en": "Principals use the project number, and permissions take minutes to propagate"
              },
              "body": {
                "es": "Al otorgar roles a identidades externas se usa el número de proyecto, no el ID: el principalSet es principalSet://iam.googleapis.com/projects/NÚMERO_DE_PROYECTO/locations/global/workloadIdentityPools/.... Además, un cambio de IAM suele aplicarse en 2 minutos pero puede tardar 7 o más.",
                "en": "When granting roles to external identities you use the project number, not the project ID: the principalSet is principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/.... Also, an IAM change usually applies within 2 minutes but can take 7 or more."
              },
              "sources": [
                "https://docs.cloud.google.com/iam/docs/workload-identity-federation",
                "https://docs.cloud.google.com/iam/docs/granting-changing-revoking-access"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "federation",
                "permissions"
              ],
              "exams": [
                "ACE"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Workload Identity Federation, service accounts e IAM no tienen costo. Pagás solo los recursos que despliega el pipeline.",
              "en": "Workload Identity Federation, service accounts and IAM are free. You only pay for what the pipeline deploys."
            }
          }
        }
      },
      "terraform": {
        "azure": {
          "providers": {
            "azurerm": "~> 5.8"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "aws": {
          "providers": {
            "aws": "~> 6.67"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "gcp": {
          "providers": {
            "google": "~> 8.6"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        }
      }
    },
    {
      "id": "hybrid-private-dns",
      "status": "published",
      "category": "networking",
      "title": {
        "es": "DNS privado híbrido (on-premises ↔ nube)",
        "en": "Hybrid private DNS (on-premises ↔ cloud)"
      },
      "summary": {
        "es": "On-premises resuelve las zonas privadas de la nube (Private Endpoints incluidos) y la nube resuelve el dominio corporativo, sin servidores DNS propios en la nube.",
        "en": "On-premises resolves the cloud's private zones (Private Endpoints included) and the cloud resolves the corporate domain, with no self-managed DNS servers in the cloud."
      },
      "problem": {
        "es": "Con un enlace VPN o dedicado ya hay conectividad IP, pero los nombres no cruzan: on-premises no puede consultar el resolver interno de la nube y los workloads de la nube no conocen corp.example.com. La solución clásica son VMs con DNS en la nube, que hay que parchear y escalar. Los requisitos son: reenvío condicional en las dos direcciones, alta disponibilidad, que los spokes resuelvan sin configuración propia y sin loops de reenvío.",
        "en": "With a VPN or dedicated link there's already IP connectivity, but names don't cross: on-premises can't query the cloud's internal resolver and cloud workloads don't know corp.example.com. The classic fix is DNS VMs in the cloud that need patching and scaling. Requirements: conditional forwarding both ways, high availability, spokes resolving with no config of their own, and no forwarding loops."
      },
      "roles": [
        {
          "id": "onprem-dns",
          "kind": "actor",
          "label": {
            "es": "DNS on-premises",
            "en": "On-prem DNS"
          }
        },
        {
          "id": "inbound",
          "kind": "capability",
          "label": {
            "es": "Entrada (on-prem → nube)",
            "en": "Inbound (on-prem → cloud)"
          },
          "capability": "dns-forwarding"
        },
        {
          "id": "private-zone",
          "kind": "capability",
          "label": {
            "es": "Zona privada de la nube",
            "en": "Cloud private zone"
          },
          "capability": "private-dns"
        },
        {
          "id": "workloads",
          "kind": "actor",
          "label": {
            "es": "Workloads en la nube",
            "en": "Cloud workloads"
          }
        },
        {
          "id": "outbound",
          "kind": "capability",
          "label": {
            "es": "Salida (nube → on-prem)",
            "en": "Outbound (cloud → on-prem)"
          },
          "capability": "dns-forwarding"
        }
      ],
      "edges": [
        {
          "from": "onprem-dns",
          "to": "inbound",
          "label": {
            "es": "reenvío condicional",
            "en": "conditional forwarder"
          }
        },
        {
          "from": "inbound",
          "to": "private-zone",
          "label": {
            "es": "resuelve",
            "en": "resolves"
          }
        },
        {
          "from": "workloads",
          "to": "private-zone"
        },
        {
          "from": "workloads",
          "to": "outbound",
          "label": {
            "es": "corp.example.com",
            "en": "corp.example.com"
          }
        },
        {
          "from": "outbound",
          "to": "onprem-dns",
          "label": {
            "es": "reenvía por VPN",
            "en": "forwards over VPN"
          }
        }
      ],
      "paths": [
        {
          "id": "onprem-to-cloud",
          "label": {
            "es": "On-prem → nube",
            "en": "On-prem → cloud"
          },
          "roles": [
            "onprem-dns",
            "inbound",
            "private-zone"
          ]
        },
        {
          "id": "cloud-to-onprem",
          "label": {
            "es": "Nube → on-prem",
            "en": "Cloud → on-prem"
          },
          "roles": [
            "workloads",
            "outbound",
            "onprem-dns"
          ]
        }
      ],
      "tags": [
        "networking",
        "dns",
        "hybrid",
        "hub-and-spoke"
      ],
      "legacy_unreviewed": true,
      "implementations": {
        "azure": {
          "provider": "azure",
          "bindings": {
            "inbound": {
              "service": {
                "es": "DNS Private Resolver: inbound endpoint",
                "en": "DNS Private Resolver: inbound endpoint"
              },
              "config": {
                "es": "IP estática en una subnet /28 dedicada y delegada",
                "en": "Static IP in a dedicated, delegated /28 subnet"
              },
              "equivalence": "full"
            },
            "private-zone": {
              "service": "Azure Private DNS",
              "config": {
                "es": "Zona vinculada al hub; los spokes la resuelven por una regla al inbound endpoint",
                "en": "Zone linked to the hub; spokes resolve it through a rule to the inbound endpoint"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "La zona vinculada a la VNet se consulta antes que el ruleset; en Route 53 una regla de forward le gana a la zona del mismo nombre.",
                  "en": "The zone linked to the VNet is checked before the ruleset; in Route 53 a forward rule beats the zone with the same name."
                }
              }
            },
            "outbound": {
              "service": {
                "es": "DNS Private Resolver: outbound endpoint + forwarding ruleset",
                "en": "DNS Private Resolver: outbound endpoint + forwarding ruleset"
              },
              "config": {
                "es": "Ruleset linkeado a los spokes (no al hub)",
                "en": "Ruleset linked to the spokes (not the hub)"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "resolver-dedicated-subnets",
              "severity": "networking",
              "title": {
                "es": "Cada endpoint necesita su propia subnet delegada, y el resolver queda atado a una VNet y una región",
                "en": "Each endpoint needs its own delegated subnet, and the resolver is tied to one VNet and one region"
              },
              "body": {
                "es": "Inbound y outbound no pueden compartir subnet: cada uno va en una dedicada de /28 a /24, delegada a Microsoft.Network/dnsResolvers y sin ningún otro recurso. Hay un solo resolver por VNet, los rulesets solo se linkean a VNets de la misma región y no funciona con subnets IPv6 ni con VNets que tienen virtual network encryption. Reservá las dos /28 en el diseño del hub antes de que el espacio se llene.",
                "en": "Inbound and outbound can't share a subnet: each goes in a dedicated /28 to /24, delegated to Microsoft.Network/dnsResolvers and with no other resources. There's one resolver per VNet, rulesets only link to VNets in the same region, and it doesn't work with IPv6 subnets or VNets with virtual network encryption. Reserve both /28s in the hub design before the address space fills up."
              },
              "sources": [
                "https://learn.microsoft.com/azure/dns/dns-private-resolver-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound",
                "outbound"
              ],
              "exams": [
                "AZ-700",
                "AZ-305"
              ]
            },
            {
              "id": "private-zone-beats-ruleset",
              "severity": "networking",
              "title": {
                "es": "En Azure la zona privada vinculada gana sobre el ruleset; en AWS es al revés",
                "en": "In Azure the linked private zone wins over the ruleset; in AWS it's the other way around"
              },
              "body": {
                "es": "Con el DNS de Azure en la VNet, primero se consultan las Private DNS zones vinculadas y recién si no hay match se evalúan las reglas del ruleset (gana el sufijo más largo). En Route 53 Resolver, una regla de forward le gana a una private hosted zone con el mismo nombre. Si migrás un diseño de \"reenviar todo corp.example.com a on-prem\" con una zona privada del mismo nombre, el comportamiento se invierte.",
                "en": "With Azure DNS on the VNet, linked Private DNS zones are checked first, and only if nothing matches are ruleset rules evaluated (longest suffix wins). In Route 53 Resolver, a forward rule beats a private hosted zone with the same name. If you migrate a \"forward all of corp.example.com to on-prem\" design with a private zone of the same name, the behavior flips."
              },
              "sources": [
                "https://learn.microsoft.com/azure/dns/dns-private-resolver-overview",
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-zone",
                "outbound"
              ],
              "exams": [
                "AZ-700"
              ]
            },
            {
              "id": "ruleset-hub-loop",
              "severity": "reliability",
              "title": {
                "es": "Linkear el ruleset al hub, con una regla que apunta a su propio inbound endpoint, arma un loop",
                "en": "Linking the ruleset to the hub, with a rule pointing at its own inbound endpoint, creates a loop"
              },
              "body": {
                "es": "El diseño típico manda la zona privada desde los spokes al inbound endpoint del hub. Si ese mismo ruleset también se linkea a la VNet del hub, las consultas del hub vuelven al hub y se arma un loop. Por eso el módulo linkea solo spokes. Si los workloads del hub necesitan resolver corp.example.com, usá un segundo ruleset sin la regla al inbound endpoint.",
                "en": "The typical design sends the private zone from spokes to the hub's inbound endpoint. If that same ruleset is also linked to the hub VNet, the hub's queries come back to the hub and loop. That's why the module links only spokes. If hub workloads need to resolve corp.example.com, use a second ruleset without the rule to the inbound endpoint."
              },
              "sources": [
                "https://learn.microsoft.com/azure/dns/private-resolver-endpoints-rulesets"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "outbound",
                "inbound"
              ],
              "exams": [
                "AZ-700"
              ]
            },
            {
              "id": "wildcard-rule-and-azure-dns-target",
              "severity": "reliability",
              "title": {
                "es": "Una regla comodín \".\" tiene que resolver nombres públicos, y 168.63.129.16 no sirve como destino",
                "en": "A wildcard \".\" rule must resolve public names, and 168.63.129.16 can't be a target"
              },
              "body": {
                "es": "Si reenviás todo con \".\", el DNS destino tiene que resolver nombres públicos: varios servicios de Azure dependen de eso. Algunos dominios reservados de Azure se ignoran igual en la regla comodín. Además, 168.63.129.16 no se acepta como destino de una regla, y Microsoft desaconseja el comodín en una VNet con un gateway de ExpressRoute porque puede romper la conectividad de gestión.",
                "en": "If you forward everything with \".\", the target DNS must resolve public names: several Azure services depend on it. Some reserved Azure domains are ignored by the wildcard rule anyway. Also, 168.63.129.16 isn't accepted as a rule target, and Microsoft advises against the wildcard in a VNet with an ExpressRoute gateway because it can break management connectivity."
              },
              "sources": [
                "https://learn.microsoft.com/azure/dns/private-resolver-endpoints-rulesets",
                "https://learn.microsoft.com/azure/architecture/networking/architecture/azure-dns-private-resolver"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "outbound"
              ],
              "exams": [
                "AZ-700",
                "AZ-104"
              ]
            },
            {
              "id": "resolver-not-lighthouse-compatible",
              "severity": "operations",
              "title": {
                "es": "DNS Private Resolver no es compatible con Azure Lighthouse ni con ExpressRoute FastPath",
                "en": "DNS Private Resolver isn't compatible with Azure Lighthouse or ExpressRoute FastPath"
              },
              "body": {
                "es": "Si un partner administra el tenant del cliente con Lighthouse, no puede operar el resolver por esa vía: hace falta acceso directo en el tenant. Tampoco soporta FastPath de ExpressRoute ni linkear rulesets entre tenants. Para un MSP es una decisión de diseño de día uno, no un detalle de implementación.",
                "en": "If a partner manages the customer's tenant with Lighthouse, it can't operate the resolver that way: it needs direct access in the tenant. It also doesn't support ExpressRoute FastPath or linking rulesets across tenants. For an MSP that's a day-one design decision, not an implementation detail."
              },
              "sources": [
                "https://learn.microsoft.com/azure/dns/dns-private-resolver-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound",
                "outbound"
              ],
              "exams": [
                "AZ-700",
                "AZ-305"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "no",
            "fixed_cost_drivers": [
              {
                "es": "Inbound endpoint (por hora)",
                "en": "Inbound endpoint (hourly)"
              },
              {
                "es": "Outbound endpoint (por hora)",
                "en": "Outbound endpoint (hourly)"
              },
              {
                "es": "Forwarding ruleset (por mes)",
                "en": "Forwarding ruleset (monthly)"
              }
            ],
            "notes": {
              "es": "Los endpoints y el ruleset se cobran aunque no haya consultas, más un cargo por consulta. Sigue siendo más barato que mantener dos VMs de DNS con alta disponibilidad.",
              "en": "Endpoints and the ruleset are billed even with zero queries, plus a per-query charge. Still cheaper than running two highly available DNS VMs."
            }
          }
        },
        "aws": {
          "provider": "aws",
          "bindings": {
            "inbound": {
              "service": "Route 53 Resolver inbound endpoint",
              "config": {
                "es": "ENIs en dos AZs; SG con UDP y TCP 53 desde on-premises",
                "en": "ENIs in two AZs; SG with UDP and TCP 53 from on-premises"
              },
              "equivalence": "full"
            },
            "private-zone": {
              "service": "Route 53 private hosted zone",
              "config": {
                "es": "Asociada a la VPC del inbound endpoint",
                "en": "Associated with the inbound endpoint's VPC"
              },
              "equivalence": "full"
            },
            "outbound": {
              "service": {
                "es": "Route 53 Resolver outbound endpoint + regla FORWARD",
                "en": "Route 53 Resolver outbound endpoint + FORWARD rule"
              },
              "config": {
                "es": "Regla asociada a cada VPC; a otras cuentas con AWS RAM",
                "en": "Rule associated with each VPC; to other accounts via AWS RAM"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "La regla FORWARD le gana a una private hosted zone con el mismo nombre; en Azure la zona vinculada gana sobre el ruleset.",
                  "en": "The FORWARD rule beats a private hosted zone with the same name; in Azure the linked zone wins over the ruleset."
                }
              }
            }
          },
          "gotchas": [
            {
              "id": "resolver-endpoints-fixed-cost",
              "severity": "cost",
              "title": {
                "es": "Cada IP de un endpoint es una ENI que se paga por hora, y el mínimo son dos por endpoint",
                "en": "Each endpoint IP is an ENI billed hourly, and the minimum is two per endpoint"
              },
              "body": {
                "es": "Route 53 Resolver cobra por ENI por hora, además de las consultas, y cada endpoint necesita al menos dos IPs en AZs distintas. Entrada más salida son cuatro ENIs facturando las 24 horas aunque no haya una sola consulta. En GCP la inbound policy y las forwarding zones no tienen cargo por hora. Si tenés varias cuentas, centralizá los endpoints en el hub y compartí las reglas con RAM en lugar de repetirlos.",
                "en": "Route 53 Resolver charges per ENI per hour, plus queries, and each endpoint needs at least two IPs in different AZs. Inbound plus outbound is four ENIs billing around the clock even with zero queries. In GCP the inbound policy and forwarding zones have no hourly charge. With several accounts, centralize the endpoints in the hub and share the rules with RAM instead of repeating them."
              },
              "sources": [
                "https://aws.amazon.com/route53/pricing/",
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-forwarding-inbound-queries-values.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound",
                "outbound"
              ],
              "exams": [
                "ANS-C01",
                "SAA-C03"
              ]
            },
            {
              "id": "vpc-plus-two-not-from-onprem",
              "severity": "networking",
              "title": {
                "es": "Reenviar desde on-premises a la IP .2 de la VPC no está soportado",
                "en": "Forwarding from on-premises to the VPC's .2 address isn't supported"
              },
              "body": {
                "es": "AWS indica que reenviar consultas DNS privadas a cualquier dirección base del CIDR de la VPC + 2 desde on-premises u otros servidores DNS de VPC no está soportado y puede dar resultados inestables. En su lugar, AWS recomienda usar un inbound endpoint de Resolver.",
                "en": "AWS states that forwarding private DNS queries to any VPC CIDR base + 2 address from on-premises or other VPC DNS servers isn't supported and can cause unstable results. Instead, AWS recommends using a Resolver inbound endpoint."
              },
              "sources": [
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-DSN-queries-to-vpc.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound"
              ],
              "exams": [
                "ANS-C01"
              ]
            },
            {
              "id": "phz-must-be-associated-with-inbound-vpc",
              "severity": "networking",
              "title": {
                "es": "La private hosted zone tiene que estar asociada a la VPC del inbound endpoint",
                "en": "The private hosted zone must be associated with the inbound endpoint's VPC"
              },
              "body": {
                "es": "Para que el Resolver de la VPC resuelva las consultas entrantes con los registros de una private hosted zone, la zona tiene que estar asociada a la VPC donde creaste el inbound endpoint.",
                "en": "For the VPC Resolver to resolve inbound DNS queries using the records in a private hosted zone, the zone must be associated with the VPC where you created the inbound endpoint."
              },
              "sources": [
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-choose-vpc.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-zone",
                "inbound"
              ],
              "exams": [
                "ANS-C01",
                "SAA-C03"
              ]
            },
            {
              "id": "forward-rule-beats-phz",
              "severity": "networking",
              "title": {
                "es": "Una regla de forward le gana a una private hosted zone con el mismo nombre",
                "en": "A forward rule beats a private hosted zone with the same name"
              },
              "body": {
                "es": "Si en una VPC hay una regla FORWARD para corp.example.com y también una private hosted zone corp.example.com, gana la regla. Entre reglas gana la más específica, y con una regla SYSTEM excluís un subdominio del reenvío.",
                "en": "If a VPC has a FORWARD rule for corp.example.com and also a corp.example.com private hosted zone, the rule wins. Among rules the most specific wins, and a SYSTEM rule excludes a subdomain from forwarding."
              },
              "sources": [
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html",
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-forward-vpc-to-network-domain-name-matches.html",
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resolver-overview-forward-vpc-to-network-using-rules.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "outbound",
                "private-zone"
              ],
              "exams": [
                "ANS-C01"
              ]
            },
            {
              "id": "qps-per-eni-connection-tracking",
              "severity": "reliability",
              "title": {
                "es": "Son 10.000 QPS por IP, pero con connection tracking pueden ser 1.500",
                "en": "It's 10,000 QPS per IP, but with connection tracking it can drop to 1,500"
              },
              "body": {
                "es": "Cada IP de un endpoint procesa hasta 10.000 consultas UDP por segundo. Si las reglas restrictivas del security group fuerzan connection tracking (o las consultas pasan por un NLB), el inbound endpoint puede bajar a 1.500 QPS por IP. Este módulo limita el origen a los rangos on-premises, así que contá con el número bajo: monitoreá la métrica por ENI y agregá IPs cuando pase el 50%.",
                "en": "Each endpoint IP handles up to 10,000 UDP queries per second. If restrictive security group rules force connection tracking (or queries go through an NLB), the inbound endpoint can drop to 1,500 QPS per IP. This module restricts the source to on-premises ranges, so plan for the low number: monitor the per-ENI metric and add IPs once it passes 50%."
              },
              "sources": [
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html",
                "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/best-practices-resolver-endpoint-scaling.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound"
              ],
              "exams": [
                "ANS-C01"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "no",
            "fixed_cost_drivers": [
              {
                "es": "4 ENIs de Resolver (2 inbound + 2 outbound) por hora",
                "en": "4 Resolver ENIs (2 inbound + 2 outbound) hourly"
              }
            ],
            "notes": {
              "es": "El costo fijo lo ponen las ENIs de los endpoints; las consultas reenviadas se cobran por millón. Las consultas que resuelve el resolver de la VPC sin pasar por un endpoint no se cobran.",
              "en": "The fixed cost comes from the endpoint ENIs; forwarded queries are billed per million. Queries the VPC resolver answers without going through an endpoint aren't charged."
            }
          }
        },
        "gcp": {
          "provider": "gcp",
          "bindings": {
            "inbound": {
              "service": {
                "es": "Cloud DNS inbound server policy",
                "en": "Cloud DNS inbound server policy"
              },
              "config": {
                "es": "enable_inbound_forwarding: una IP de entrada por subnet de la VPC",
                "en": "enable_inbound_forwarding: one entry IP per subnet in the VPC"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "No hay endpoints que dimensionar ni ENIs por hora: la policy toma una IP de cada subnet de la VPC.",
                  "en": "There are no endpoints to size and no hourly ENIs: the policy takes one IP from every subnet in the VPC."
                },
                "azure": {
                  "es": "No hay endpoints ni subnets dedicadas: la policy toma una IP de cada subnet de la VPC.",
                  "en": "There are no endpoints or dedicated subnets: the policy takes one IP from every subnet in the VPC."
                }
              }
            },
            "private-zone": {
              "service": "Cloud DNS private zone",
              "config": {
                "es": "Visible para la VPC hub",
                "en": "Visible to the hub VPC"
              },
              "equivalence": "full"
            },
            "outbound": {
              "service": {
                "es": "Cloud DNS forwarding zone",
                "en": "Cloud DNS forwarding zone"
              },
              "config": {
                "es": "forwarding_path = private + Cloud Router anunciando 35.199.192.0/19",
                "en": "forwarding_path = private + Cloud Router advertising 35.199.192.0/19"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.",
                  "en": "Forwarded queries come from 35.199.192.0/19, not from your VPC's IPs: on-premises must allow that range and have a return route."
                },
                "aws": {
                  "es": "Las consultas reenviadas salen desde 35.199.192.0/19, no desde IPs de tu VPC: on-premises tiene que permitir ese rango y tener la ruta de vuelta.",
                  "en": "Forwarded queries come from 35.199.192.0/19, not from your VPC's IPs: on-premises must allow that range and have a return route."
                }
              }
            }
          },
          "gotchas": [
            {
              "id": "return-route-35-199-192-0-19",
              "severity": "networking",
              "title": {
                "es": "Hacia targets Type 1 y 2 las consultas salen desde 35.199.192.0/19; con Type 2, on-premises necesita ruta",
                "en": "To Type 1 and 2 targets queries come from 35.199.192.0/19; with Type 2, on-premises needs a route to it"
              },
              "body": {
                "es": "Cloud DNS usa el rango 35.199.192.0/19 para todos los clientes, y es el origen de las consultas a targets Type 1 y Type 2 (IP interna de VPC y on-premises por VPN o Interconnect). Los targets Type 3 usan los rangos de Google Public DNS, y en Type 4 el rango depende de las IPs a las que resuelve el nombre. Para un target Type 2, on-premises necesita una ruta a 35.199.192.0/19 cuyo next hop esté en la misma VPC, por un túnel Cloud VPN o un attachment de Interconnect. Con BGP eso es un anuncio de ruta custom en la sesión BGP del Cloud Router.",
                "en": "Cloud DNS uses the 35.199.192.0/19 range for all customers, and it is the source of queries to Type 1 and Type 2 targets (VPC internal IPs and on-premises over VPN or Interconnect). Type 3 targets use Google Public DNS source ranges, and for Type 4 the range depends on the IPs the name resolves to. For a Type 2 target, on-premises needs a route to 35.199.192.0/19 whose next hop is in the same VPC, through a Cloud VPN tunnel or a Cloud Interconnect VLAN attachment. With BGP that's a custom route advertisement on the Cloud Router's BGP session."
              },
              "sources": [
                "https://docs.cloud.google.com/dns/docs/zones/forwarding-zones",
                "https://docs.cloud.google.com/dns/docs/server-policies-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "outbound"
              ],
              "exams": [
                "PCNE",
                "PCA"
              ]
            },
            {
              "id": "forwarding-path-private",
              "severity": "security",
              "title": {
                "es": "Con routing estándar, un destino que no es RFC 1918 se consulta por internet",
                "en": "With standard routing, a non-RFC 1918 target is queried over the internet"
              },
              "body": {
                "es": "El default de una forwarding zone decide la ruta según la IP del destino: RFC 1918 va por la VPC, cualquier otra va por internet. Si tu DNS on-premises usa IPs públicas reutilizadas internamente o rangos no RFC 1918, las consultas salen a internet y no llegan. forwarding_path = \"private\" fuerza siempre la VPC.",
                "en": "A forwarding zone's default picks the path by target IP: RFC 1918 goes through the VPC, anything else goes over the internet. If your on-premises DNS uses internally reused public IPs or non-RFC 1918 ranges, queries go out to the internet and never arrive. forwarding_path = \"private\" always forces the VPC."
              },
              "sources": [
                "https://docs.cloud.google.com/dns/docs/zones/zones-overview",
                "https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/dns_managed_zone"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "outbound"
              ],
              "exams": [
                "PCNE"
              ]
            },
            {
              "id": "inbound-entry-point-per-subnet",
              "severity": "networking",
              "title": {
                "es": "La inbound policy toma una IP de cada subnet, no una por región ni dos por endpoint",
                "en": "The inbound policy takes one IP from every subnet, not one per region or two per endpoint"
              },
              "body": {
                "es": "No hay endpoints que dimensionar: la policy reserva una IP interna del rango primario de cada subnet de la VPC (menos las de propósito especial), así que una VPC con muchas subnets tiene muchas IPs de entrada. Lo que cuenta para la respuesta es la región del túnel o attachment por donde llegó la consulta, no la de la subnet de la IP. Poné la policy en la VPC que tiene la VPN o el Interconnect.",
                "en": "There are no endpoints to size: the policy reserves one internal IP from the primary range of every subnet in the VPC (except special-purpose ones), so a VPC with many subnets has many entry IPs. What matters for the response is the region of the tunnel or attachment the query arrived on, not the subnet's region. Put the policy on the VPC that has the VPN or Interconnect."
              },
              "sources": [
                "https://docs.cloud.google.com/dns/docs/server-policies-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "inbound"
              ],
              "exams": [
                "PCNE",
                "ACE"
              ]
            },
            {
              "id": "dns-not-shared-over-peering",
              "severity": "networking",
              "title": {
                "es": "VPC Network Peering no comparte las zonas privadas; hacen falta peering zones",
                "en": "VPC Network Peering doesn't share private zones; you need peering zones"
              },
              "body": {
                "es": "VPC Network Peering no comparte automáticamente la información de DNS, así que una VPC peereada no resuelve por sí sola las zonas de la VPC hub. En hub-and-spoke, cada spoke necesita una DNS peering zone hacia el hub, y el DNS peering transitivo llega a un solo salto. Es la diferencia con Azure, donde un ruleset se linkea a VNets que ni siquiera están peereadas.",
                "en": "VPC Network Peering doesn't automatically share DNS information, so a peered VPC doesn't resolve the hub VPC's zones on its own. In hub-and-spoke, each spoke needs a DNS peering zone to the hub, and transitive DNS peering only goes one hop. That's the difference from Azure, where a ruleset links to VNets that aren't even peered."
              },
              "sources": [
                "https://docs.cloud.google.com/dns/docs/zones/peering-zones",
                "https://docs.cloud.google.com/dns/docs/zones/zones-overview",
                "https://learn.microsoft.com/azure/dns/private-resolver-endpoints-rulesets"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-zone"
              ],
              "exams": [
                "PCNE",
                "PCA"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "partial",
            "fixed_cost_drivers": [
              {
                "es": "Zonas administradas (privada y de forwarding), por mes",
                "en": "Managed zones (private and forwarding), monthly"
              }
            ],
            "notes": {
              "es": "No hay cargo por hora de endpoints. Pagás cada zona por mes, se use o no, y las consultas por millón. El Cloud Router y la VPN se pagan aparte y suelen existir ya por la conectividad.",
              "en": "There's no hourly endpoint charge. You pay each zone monthly, used or not, and queries per million. The Cloud Router and VPN are billed separately and usually already exist for connectivity."
            }
          }
        }
      },
      "terraform": {
        "azure": {
          "providers": {
            "azurerm": "~> 5.8"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "aws": {
          "providers": {
            "aws": "~> 6.67"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "gcp": {
          "providers": {
            "google": "~> 8.6"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        }
      }
    },
    {
      "id": "object-storage-ttl",
      "status": "published",
      "category": "storage-data",
      "title": {
        "es": "Ingesta privada de objetos con expiración (TTL)",
        "en": "Private object ingestion with expiration (TTL)"
      },
      "summary": {
        "es": "Un workload sube archivos a un bucket privado, sin claves, por red privada, y los archivos se borran solos a los N días.",
        "en": "A workload uploads files to a private bucket, keyless, over a private network, and files delete themselves after N days."
      },
      "problem": {
        "es": "Necesitás recibir archivos temporales (exports, uploads de usuarios, staging de ETL) que no deben quedar expuestos ni acumularse. Los requisitos son: cero acceso público, cero claves estáticas, tráfico por red privada, permisos acotados al prefijo de ingesta y borrado automático tras N días.",
        "en": "You need to receive temporary files (exports, user uploads, ETL staging) that must not be exposed or pile up. Requirements: zero public access, zero static keys, private-network traffic, permissions scoped to the ingestion prefix, and automatic deletion after N days."
      },
      "roles": [
        {
          "id": "producer",
          "kind": "actor",
          "label": {
            "es": "Workload productor",
            "en": "Producer workload"
          }
        },
        {
          "id": "identity",
          "kind": "capability",
          "label": {
            "es": "Identidad sin secretos",
            "en": "Secretless identity"
          },
          "capability": "workload-identity"
        },
        {
          "id": "private-access",
          "kind": "capability",
          "label": {
            "es": "Acceso privado",
            "en": "Private access"
          },
          "capability": "private-service-access"
        },
        {
          "id": "bucket",
          "kind": "capability",
          "label": {
            "es": "Almacenamiento de objetos",
            "en": "Object storage"
          },
          "capability": "object-storage"
        },
        {
          "id": "lifecycle",
          "kind": "capability",
          "label": {
            "es": "Política de expiración",
            "en": "Expiration policy"
          },
          "capability": "object-lifecycle"
        },
        {
          "id": "perimeter",
          "kind": "capability",
          "label": {
            "es": "Perímetro de datos",
            "en": "Data perimeter"
          },
          "capability": "data-perimeter"
        }
      ],
      "edges": [
        {
          "from": "producer",
          "to": "identity",
          "label": {
            "es": "obtiene token",
            "en": "gets token"
          }
        },
        {
          "from": "producer",
          "to": "private-access",
          "label": {
            "es": "PUT objeto",
            "en": "PUT object"
          }
        },
        {
          "from": "private-access",
          "to": "bucket"
        },
        {
          "from": "lifecycle",
          "to": "bucket",
          "label": {
            "es": "borra a los N días",
            "en": "deletes after N days"
          }
        },
        {
          "from": "perimeter",
          "to": "bucket",
          "label": {
            "es": "restringe origen",
            "en": "restricts origin"
          }
        }
      ],
      "paths": [
        {
          "id": "write",
          "label": {
            "es": "Escritura",
            "en": "Write"
          },
          "roles": [
            "producer",
            "private-access",
            "bucket"
          ]
        },
        {
          "id": "token",
          "label": {
            "es": "Token",
            "en": "Token"
          },
          "roles": [
            "producer",
            "identity"
          ]
        },
        {
          "id": "expiration",
          "label": {
            "es": "Expiración",
            "en": "Expiration"
          },
          "roles": [
            "lifecycle",
            "bucket"
          ]
        },
        {
          "id": "perimeter",
          "label": {
            "es": "Perímetro",
            "en": "Perimeter"
          },
          "roles": [
            "perimeter",
            "bucket"
          ]
        }
      ],
      "tags": [
        "storage",
        "lifecycle",
        "zero-trust",
        "private-networking"
      ],
      "legacy_unreviewed": true,
      "implementations": {
        "azure": {
          "provider": "azure",
          "bindings": {
            "identity": {
              "service": "User-assigned Managed Identity",
              "config": {
                "es": "Storage Blob Data Contributor sobre el contenedor",
                "en": "Storage Blob Data Contributor on the container"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "El scope mínimo de un role assignment es el contenedor, no un prefijo como en una policy de S3: para acotar por ruta hace falta una condición ABAC.",
                  "en": "The smallest role assignment scope is the container, not a prefix as in an S3 policy: scoping by path needs an ABAC condition."
                }
              }
            },
            "private-access": {
              "service": "Private Endpoint (subresource blob)",
              "config": {
                "es": "+ zona privatelink.blob.core.windows.net",
                "en": "+ privatelink.blob.core.windows.net zone"
              },
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "Además del endpoint necesitás la zona privatelink.blob.core.windows.net vinculada a la VNet; sin ella se resuelve la IP pública y la request da 403.",
                  "en": "Besides the endpoint you need the privatelink.blob.core.windows.net zone linked to the VNet; without it the public IP resolves and the request gets a 403."
                }
              }
            },
            "bucket": {
              "service": "Storage Account (StorageV2) + container",
              "config": {
                "es": "Shared Key deshabilitado, acceso público de red deshabilitado",
                "en": "Shared Key disabled, public network access disabled"
              },
              "equivalence": "full"
            },
            "lifecycle": {
              "service": "Lifecycle management policy",
              "config": "delete_after_days_since_creation_greater_than",
              "equivalence": "full",
              "from": {
                "aws": {
                  "es": "El prefijo del filtro incluye el nombre del contenedor: un \"incoming/\" copiado de S3 no matchea nada y no da error.",
                  "en": "The filter prefix includes the container name: an \"incoming/\" copied from S3 matches nothing and raises no error."
                },
                "gcp": {
                  "es": "El prefijo del filtro incluye el nombre del contenedor, y con soft delete activo el borrado no libera capacidad hasta que vence la retención.",
                  "en": "The filter prefix includes the container name, and with soft delete on, deletion doesn't free capacity until retention expires."
                }
              }
            },
            "perimeter": {
              "service": "Storage firewall",
              "config": "public_network_access = Disabled",
              "notes": {
                "es": "Para un perímetro a nivel organización entre varios PaaS, ver Network Security Perimeter.",
                "en": "For an organization-level perimeter across several PaaS services, see Network Security Perimeter."
              },
              "equivalence": "partial"
            }
          },
          "gotchas": [
            {
              "id": "lifecycle-not-exact-ttl",
              "severity": "reliability",
              "title": {
                "es": "La policy tarda hasta 24 h en aplicarse y corre en background",
                "en": "The policy takes up to 24 h to apply and runs in the background"
              },
              "body": {
                "es": "Al crear o editar una lifecycle policy puede tardar hasta 24 h en entrar en vigor, y cada corrida puede llevar más de un día en cuentas grandes. No uses lifecycle como control de seguridad (\"el archivo ya no existe a las 48 h\"): si el requisito es estricto, borrá desde la aplicación o usá un job propio.",
                "en": "A new or edited lifecycle policy can take up to 24 h to take effect, and each run can take more than a day on large accounts. Don't use lifecycle as a security control (\"the file is gone at 48 h\"): if the requirement is strict, delete from the application or run your own job."
              },
              "sources": [
                "https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview",
                "https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-performance-characteristics"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle"
              ],
              "exams": [
                "AZ-104"
              ]
            },
            {
              "id": "soft-delete-keeps-capacity",
              "severity": "cost",
              "title": {
                "es": "Con soft delete activo, \"borrar\" no libera capacidad",
                "en": "With soft delete on, \"delete\" does not free capacity"
              },
              "body": {
                "es": "Si la cuenta tiene soft delete de blobs, la acción delete de lifecycle deja el blob en estado soft-deleted durante el período de retención, y lo seguís pagando. Tu TTL efectivo de costo es ttl_days + días de soft delete. El módulo lo expone como variable para que sea una decisión explícita.",
                "en": "If blob soft delete is on, the lifecycle delete action leaves the blob soft-deleted for the retention period, and you keep paying for it. Your effective cost TTL is ttl_days + soft-delete days. The module exposes it as a variable so it's an explicit decision."
              },
              "sources": [
                "https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-policy-delete",
                "https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-policy-faq"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle",
                "bucket"
              ],
              "exams": [
                "AZ-104"
              ]
            },
            {
              "id": "prefix-includes-container",
              "severity": "reliability",
              "title": {
                "es": "En Azure el prefijo del filtro incluye el nombre del contenedor",
                "en": "In Azure the filter prefix includes the container name"
              },
              "body": {
                "es": "prefix_match es \"contenedor/ruta\", no solo la ruta. Un filtro \"incoming/\" que en S3 o Cloud Storage funciona, en Azure matchea un contenedor llamado \"incoming\" y nunca borra nada. El error no es visible: la policy se crea sin fallar.",
                "en": "prefix_match is \"container/path\", not just the path. An \"incoming/\" filter that works in S3 or Cloud Storage matches a container named \"incoming\" in Azure and never deletes anything. The failure is silent: the policy is created without error."
              },
              "sources": [
                "https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-policy-structure"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle"
              ],
              "exams": [
                "AZ-104"
              ]
            },
            {
              "id": "shared-key-needs-azuread-provider",
              "severity": "iam",
              "title": {
                "es": "Deshabilitar Shared Key obliga a que Terraform use Entra ID",
                "en": "Disabling Shared Key forces Terraform to use Entra ID"
              },
              "body": {
                "es": "Con shared_access_key_enabled = false, toda llamada de data plane tiene que autenticarse con Entra ID, incluida la del provider. Configurá storage_use_azuread = true en el provider azurerm y dale a la identidad del pipeline un rol de datos (no alcanza con Contributor, que es solo control plane).",
                "en": "With shared_access_key_enabled = false, every data-plane call must authenticate with Entra ID, including the provider's own. Set storage_use_azuread = true on the azurerm provider and grant the pipeline identity a data role (Contributor is control plane only and is not enough)."
              },
              "sources": [
                "https://learn.microsoft.com/azure/storage/common/shared-key-authorization-prevent",
                "https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "bucket",
                "identity"
              ],
              "exams": [
                "AZ-104",
                "SC-500"
              ]
            },
            {
              "id": "rbac-scope-is-container",
              "severity": "iam",
              "title": {
                "es": "Los role assignments son por cuenta o contenedor; por prefijo hacen falta condiciones ABAC",
                "en": "Role assignments are per account or container; by prefix you need ABAC conditions"
              },
              "body": {
                "es": "Azure Storage admite role assignments a nivel de cuenta y de contenedor. Para acotar por prefijo o ruta hacen falta condiciones ABAC sobre el role assignment (atributos Blob prefix y Blob path). Alternativa simple: un contenedor dedicado a la ingesta (lo que hace este módulo).",
                "en": "Azure Storage supports role assignments for both storage accounts and blob containers. To scope by prefix or path you need ABAC conditions on the role assignment (Blob prefix and Blob path attributes). Simple alternative: a container dedicated to ingestion (what this module does)."
              },
              "sources": [
                "https://learn.microsoft.com/azure/storage/blobs/storage-auth-abac",
                "https://learn.microsoft.com/azure/storage/blobs/storage-auth-abac-attributes"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "identity"
              ],
              "exams": [
                "AZ-104",
                "SC-500"
              ]
            },
            {
              "id": "private-endpoint-needs-dns",
              "severity": "networking",
              "title": {
                "es": "Private Endpoint sin zona DNS privada: el firewall del servicio puede responder 403",
                "en": "Private Endpoint without a private DNS zone: the service firewall can answer 403"
              },
              "body": {
                "es": "Crear el Private Endpoint no alcanza: el FQDN <cuenta>.blob.core.windows.net tiene que resolver a la IP privada vía la zona privatelink.blob.core.windows.net vinculada a la VNet (o a tu DNS híbrido). Si resuelve a la IP pública y la cuenta tiene firewall, este rechaza la request con 403 y parece un problema de permisos.",
                "en": "Creating the Private Endpoint is not enough: the FQDN <account>.blob.core.windows.net must resolve to the private IP via the privatelink.blob.core.windows.net zone linked to the VNet (or your hybrid DNS). If it resolves to the public IP and the account has a firewall, it rejects the request with 403 and looks like a permissions issue."
              },
              "sources": [
                "https://learn.microsoft.com/azure/private-link/private-endpoint-dns",
                "https://learn.microsoft.com/azure/private-link/private-endpoint-overview",
                "https://learn.microsoft.com/troubleshoot/azure/private-link/troubleshoot-403-access-denied-private-endpoint"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-access"
              ],
              "exams": [
                "AZ-700",
                "AZ-104"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "partial",
            "fixed_cost_drivers": [
              {
                "es": "Private Endpoint (cargo por hora, más GB procesados)",
                "en": "Private Endpoint (hourly charge, plus processed GB)"
              },
              {
                "es": "Zona DNS privada (cargo mensual por zona)",
                "en": "Private DNS zone (monthly charge per zone)"
              }
            ],
            "notes": {
              "es": "El storage escala a cero (pagás GB y operaciones). El costo fijo lo pone la red privada. Es la diferencia principal con AWS, donde el Gateway Endpoint de S3 no tiene costo.",
              "en": "Storage scales to zero (you pay GB and operations). The fixed cost comes from private networking. That's the main difference with AWS, where the S3 Gateway Endpoint is free."
            }
          }
        },
        "aws": {
          "provider": "aws",
          "bindings": {
            "identity": {
              "service": "IAM Role (ECS task role)",
              "config": {
                "es": "s3:PutObject acotado a bucket/incoming/*",
                "en": "s3:PutObject scoped to bucket/incoming/*"
              },
              "equivalence": "full"
            },
            "private-access": {
              "service": {
                "es": "Gateway VPC Endpoint para S3",
                "en": "S3 Gateway VPC Endpoint"
              },
              "config": {
                "es": "asociado a la route table privada",
                "en": "attached to the private route table"
              },
              "notes": {
                "es": "Es una ruta en la route table, no una IP privada: no llega desde on-premises ni desde otra región (para eso, Interface Endpoint).",
                "en": "It's a route-table entry, not a private IP: it isn't reachable from on-premises or another region (that takes an Interface Endpoint)."
              },
              "equivalence": "partial",
              "from": {
                "azure": {
                  "es": "No es un Private Endpoint: es una entrada gratis en la route table que no llega desde on-premises ni desde otra región. El equivalente real es un Interface Endpoint, que se cobra por hora.",
                  "en": "It isn't a Private Endpoint: it's a free route-table entry that isn't reachable from on-premises or another region. The real equivalent is an Interface Endpoint, billed hourly."
                }
              }
            },
            "bucket": {
              "service": "S3 bucket",
              "config": "Block Public Access + BucketOwnerEnforced + SSE-KMS",
              "equivalence": "full"
            },
            "lifecycle": {
              "service": "S3 Lifecycle rule",
              "config": "Expiration.Days + AbortIncompleteMultipartUpload",
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "El filtro es solo el prefijo dentro del bucket, y el cobro se corta en la fecha de expiración aunque el borrado físico tarde días.",
                  "en": "The filter is just the prefix inside the bucket, and billing stops at the expiration date even if physical deletion takes days."
                }
              }
            },
            "perimeter": {
              "service": "Bucket policy",
              "config": {
                "es": "Deny si aws:SourceVpce ≠ endpoint",
                "en": "Deny if aws:SourceVpce ≠ endpoint"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "lifecycle-async-billing-stops",
              "severity": "cost",
              "title": {
                "es": "La expiración es asincrónica, pero el cobro se corta en la fecha de expiración",
                "en": "Expiration is asynchronous, but billing stops at the expiration date"
              },
              "body": {
                "es": "S3 encola las expiraciones y las procesa de forma asincrónica: puede pasar desde días hasta semanas entre la fecha de expiración y el borrado real. No se cobra la expiración ni el tiempo de storage de un objeto que ya venció.",
                "en": "S3 queues expirations and processes them asynchronously: anywhere from days to weeks can pass between the expiration date and the actual deletion. You're not charged for the expiration or for the storage time of an object that has already expired."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-expire-general-considerations.html",
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-set-lifecycle-configuration-intro.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "versioning-delete-marker",
              "severity": "cost",
              "title": {
                "es": "En un bucket versionado, Expiration no borra nada",
                "en": "On a versioned bucket, Expiration deletes nothing"
              },
              "body": {
                "es": "Con versioning habilitado, la acción Expiration solo agrega un delete marker y la versión pasa a noncurrent, que se sigue cobrando. Necesitás además NoncurrentVersionExpiration (y limpiar delete markers huérfanos). Este patrón deja el bucket sin versioning a propósito: el TTL es la protección que se busca.",
                "en": "With versioning on, the Expiration action only adds a delete marker and the version becomes noncurrent, which is still billed. You also need NoncurrentVersionExpiration (and to clean orphaned delete markers). This pattern leaves the bucket unversioned on purpose: the TTL is the desired behavior."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-expire-general-considerations.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle",
                "bucket"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "abort-incomplete-multipart",
              "severity": "cost",
              "title": {
                "es": "Los multipart uploads incompletos se siguen cobrando",
                "en": "Incomplete multipart uploads keep being billed"
              },
              "body": {
                "es": "S3 retiene las partes de un multipart upload hasta que lo completás o lo abortás, y se cobra su storage durante todo ese tiempo. Agregá siempre una regla AbortIncompleteMultipartUpload: abortar el upload borra las partes.",
                "en": "S3 keeps the parts of a multipart upload until you complete or stop it, and their storage is billed the whole time. Always add an AbortIncompleteMultipartUpload rule: aborting the upload deletes the parts."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html",
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "secure-defaults-since-2023",
              "severity": "security",
              "title": {
                "es": "Block Public Access y ACLs deshabilitadas son default en buckets nuevos (pero declaralos igual)",
                "en": "Block Public Access and disabled ACLs are default on new buckets (but declare them anyway)"
              },
              "body": {
                "es": "Por default, Object Ownership es BucketOwnerEnforced y todas las ACLs están deshabilitadas. AWS anunció que, a partir de abril de 2023, S3 habilitaría Block Public Access y deshabilitaría las ACLs en todos los buckets nuevos, sin cambios para los existentes. Igual declaralos en IaC: declarar el estado evita drift si alguien los toca desde la consola.",
                "en": "By default, Object Ownership is BucketOwnerEnforced and all ACLs are disabled. AWS announced that, starting in April 2023, S3 would enable Block Public Access and disable ACLs on all new buckets, with no change for existing ones. Still declare them in IaC: declaring the state prevents drift if someone edits them in the console."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html",
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html",
                "https://aws.amazon.com/about-aws/whats-new/2022/12/amazon-s3-automatically-enable-block-public-access-disable-access-control-lists-buckets-april-2023/"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "bucket"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "gateway-endpoint-scope",
              "severity": "networking",
              "title": {
                "es": "El Gateway Endpoint es gratis, pero solo sirve dentro de la VPC y la región",
                "en": "The Gateway Endpoint is free, but only works within the VPC and region"
              },
              "body": {
                "es": "Es una entrada en la route table, sin costo por hora ni por GB, y por eso es la opción por defecto para S3. Pero no es alcanzable desde on-premises (VPN/Direct Connect) ni desde otra región. Para esos casos necesitás un Interface Endpoint, que sí se cobra por hora por AZ y es el equivalente real de Private Endpoint en Azure.",
                "en": "It's a route table entry with no hourly or per-GB charge, which is why it's the default for S3. But it's not reachable from on-premises (VPN/Direct Connect) or from another region. For those cases you need an Interface Endpoint, which is billed hourly per AZ and is the real equivalent of Azure Private Endpoint."
              },
              "sources": [
                "https://docs.aws.amazon.com/vpc/latest/privatelink/gateway-endpoints.html",
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/privatelink-interface-endpoints.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-access"
              ],
              "exams": [
                "SAA-C03",
                "ANS-C01"
              ]
            },
            {
              "id": "sourcevpce-lockout",
              "severity": "iam",
              "title": {
                "es": "Un Deny por aws:SourceVpce también te deja afuera a vos",
                "en": "A Deny on aws:SourceVpce locks you out too"
              },
              "body": {
                "es": "Un Deny explícito gana sobre cualquier Allow, incluido el del admin. Si la policy niega todo s3:* fuera del endpoint, la consola y el pipeline de CI (que no están en la VPC) pierden acceso, y podés terminar sin poder editar la propia bucket policy. Este módulo limita el Deny a operaciones de objetos y permite excluir principals de break-glass con aws:PrincipalArn.",
                "en": "An explicit Deny beats any Allow, including the admin's. If the policy denies all s3:* outside the endpoint, the console and the CI pipeline (outside the VPC) lose access, and you can end up unable to edit the bucket policy itself. This module limits the Deny to object operations and lets you exclude break-glass principals via aws:PrincipalArn."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonS3/latest/userguide/example-bucket-policies-vpc-endpoint.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "perimeter"
              ],
              "exams": [
                "SAA-C03",
                "SCS-C03"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Sin costo fijo: el Gateway Endpoint es gratis y la clave KMS administrada por AWS (aws/s3) no tiene cargo mensual; Bucket Keys reduce las llamadas a KMS que sí se cobran por request.",
              "en": "No fixed cost: the Gateway Endpoint is free and the AWS-managed KMS key (aws/s3) has no monthly fee; Bucket Keys reduce the KMS calls that are billed per request."
            }
          }
        },
        "gcp": {
          "provider": "gcp",
          "bindings": {
            "identity": {
              "service": {
                "es": "Service Account adjunta al workload",
                "en": "Service Account attached to the workload"
              },
              "config": {
                "es": "roles/storage.objectCreator con IAM Condition por prefijo",
                "en": "roles/storage.objectCreator with a prefix IAM Condition"
              },
              "equivalence": "full"
            },
            "private-access": {
              "service": "Private Google Access",
              "config": {
                "es": "flag en la subnet",
                "en": "subnet flag"
              },
              "notes": {
                "es": "Da salida a las APIs de Google sin IP pública, pero no es un endpoint privado del bucket ni restringe quién accede.",
                "en": "Reaches Google APIs without a public IP, but it isn't a private endpoint for the bucket and doesn't restrict who reaches it."
              },
              "equivalence": "partial",
              "from": {
                "azure": {
                  "es": "No es un Private Endpoint: no hay IP privada del bucket. Para restringir el origen hace falta bucket IP filtering o VPC Service Controls.",
                  "en": "It isn't a Private Endpoint: the bucket gets no private IP. Restricting the origin takes bucket IP filtering or VPC Service Controls."
                }
              }
            },
            "bucket": {
              "service": "Cloud Storage bucket",
              "config": "Uniform bucket-level access + Public access prevention enforced",
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.",
                  "en": "Soft delete is on by default (7 days): an ephemeral object is billed for an extra week unless you set it to 0."
                },
                "aws": {
                  "es": "Soft delete viene activo por default (7 días): un objeto efímero se cobra una semana más salvo que lo pongas en 0.",
                  "en": "Soft delete is on by default (7 days): an ephemeral object is billed for an extra week unless you set it to 0."
                }
              }
            },
            "lifecycle": {
              "service": "Object Lifecycle Management",
              "config": {
                "es": "Delete por age + AbortIncompleteMultipartUpload",
                "en": "Delete by age + AbortIncompleteMultipartUpload"
              },
              "equivalence": "full"
            },
            "perimeter": {
              "service": {
                "es": "Bucket IP filtering (opcional)",
                "en": "Bucket IP filtering (optional)"
              },
              "notes": {
                "es": "El perímetro completo es VPC Service Controls, que se configura a nivel organización.",
                "en": "The full perimeter is VPC Service Controls, configured at organization level."
              },
              "equivalence": "partial"
            }
          },
          "gotchas": [
            {
              "id": "soft-delete-default-7-days",
              "severity": "cost",
              "title": {
                "es": "Soft delete viene activo por default (7 días) y encarece los datos efímeros",
                "en": "Soft delete is on by default (7 days) and makes ephemeral data more expensive"
              },
              "body": {
                "es": "Todos los buckets tienen soft delete con 7 días de retención salvo que lo cambies. Un objeto que vive una hora se cobra esa hora más 7 días de soft delete. En un bucket de ingesta con TTL eso puede multiplicar el costo. El módulo lo pone en 0 por default (deshabilitado) y lo deja como variable explícita.",
                "en": "Every bucket has soft delete with 7-day retention unless you change it. An object that lives one hour is billed for that hour plus 7 days of soft delete. In a TTL ingestion bucket that can multiply cost. The module sets it to 0 (disabled) by default and exposes it as an explicit variable."
              },
              "sources": [
                "https://docs.cloud.google.com/storage/docs/soft-delete"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "bucket",
                "lifecycle"
              ],
              "exams": [
                "ACE",
                "PCA"
              ]
            },
            {
              "id": "lifecycle-up-to-24h",
              "severity": "reliability",
              "title": {
                "es": "Los cambios de lifecycle pueden tardar hasta 24 h y la acción es asincrónica",
                "en": "Lifecycle changes can take up to 24 h and actions are asynchronous"
              },
              "body": {
                "es": "Igual que en Azure y S3, no es un TTL exacto: un objeto que cumple la condición puede seguir existiendo un tiempo. Si necesitás garantía de borrado, hacelo desde la aplicación.",
                "en": "Same as Azure and S3, it isn't an exact TTL: an object meeting the condition may still exist for a while. If you need a deletion guarantee, do it from the application."
              },
              "sources": [
                "https://docs.cloud.google.com/storage/docs/lifecycle"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "lifecycle"
              ],
              "exams": [
                "ACE"
              ]
            },
            {
              "id": "objectcreator-cannot-overwrite",
              "severity": "iam",
              "title": {
                "es": "roles/storage.objectCreator no alcanza para sobrescribir",
                "en": "roles/storage.objectCreator is not enough to overwrite"
              },
              "body": {
                "es": "Sobrescribir un objeto existente requiere storage.objects.delete, que objectCreator no tiene. Para ingesta es una ventaja (un productor comprometido no puede pisar archivos), pero rompe clientes que reintentan con el mismo nombre. En S3, s3:PutObject sí sobrescribe.",
                "en": "Overwriting an existing object requires storage.objects.delete, which objectCreator lacks. For ingestion that's a feature (a compromised producer can't clobber files), but it breaks clients that retry with the same name. In S3, s3:PutObject does overwrite."
              },
              "sources": [
                "https://docs.cloud.google.com/storage/docs/access-control/iam-roles",
                "https://docs.cloud.google.com/storage/docs/access-control/iam-permissions"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "identity"
              ],
              "exams": [
                "ACE",
                "PCSE"
              ]
            },
            {
              "id": "pga-is-not-a-perimeter",
              "severity": "networking",
              "title": {
                "es": "Private Google Access da alcance a las APIs de Google; filtrar el origen del bucket es otro control",
                "en": "Private Google Access gives reach to Google APIs; filtering the bucket's origin is another control"
              },
              "body": {
                "es": "Las VMs que solo tienen IP interna pueden usar Private Google Access para llegar a las APIs y servicios de Google. Eso resuelve el alcance, no el filtrado de origen. Bucket IP filtering permite restringir el acceso al bucket según rangos de IP del cliente y redes VPC, y funciona junto con IAM.",
                "en": "VM instances that only have internal IP addresses can use Private Google Access to reach Google APIs and services. That solves reachability, not origin filtering. Bucket IP filtering lets you restrict access to the bucket based on client IP ranges and VPC networks, and works alongside IAM."
              },
              "sources": [
                "https://docs.cloud.google.com/vpc/docs/private-google-access",
                "https://docs.cloud.google.com/storage/docs/ip-filtering-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "private-access",
                "perimeter"
              ],
              "exams": [
                "PCNE",
                "PCA"
              ]
            },
            {
              "id": "ubla-and-pap",
              "severity": "security",
              "title": {
                "es": "Uniform bucket-level access + public access prevention son el par a declarar",
                "en": "Uniform bucket-level access + public access prevention is the pair to declare"
              },
              "body": {
                "es": "UBLA deshabilita las ACLs por objeto (equivalente a BucketOwnerEnforced en S3) y es requisito para usar IAM Conditions. Public access prevention \"enforced\" bloquea allUsers/allAuthenticatedUsers (equivalente a Block Public Access).",
                "en": "UBLA disables per-object ACLs (equivalent to BucketOwnerEnforced in S3) and is required for IAM Conditions. Public access prevention \"enforced\" blocks allUsers/allAuthenticatedUsers (equivalent to Block Public Access)."
              },
              "sources": [
                "https://docs.cloud.google.com/storage/docs/uniform-bucket-level-access",
                "https://docs.cloud.google.com/storage/docs/public-access-prevention"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "bucket"
              ],
              "exams": [
                "ACE",
                "PCSE"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "yes",
            "fixed_cost_drivers": [],
            "notes": {
              "es": "Sin costo fijo: Private Google Access no tiene cargo. Ojo con soft delete (ver gotcha): es el costo oculto de este patrón en GCP.",
              "en": "No fixed cost: Private Google Access is free. Watch out for soft delete (see gotcha): it's this pattern's hidden cost on GCP."
            }
          }
        }
      },
      "terraform": {
        "azure": {
          "providers": {
            "azurerm": "~> 5.8"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "aws": {
          "providers": {
            "aws": "~> 6.67"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "gcp": {
          "providers": {
            "google": "~> 8.6"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        }
      }
    },
    {
      "id": "private-web-api",
      "status": "published",
      "category": "compute-web",
      "title": {
        "es": "Web API privada Zero Trust con PostgreSQL sin contraseñas",
        "en": "Zero Trust private web API with passwordless PostgreSQL"
      },
      "summary": {
        "es": "Un contenedor HTTP alcanzable solo desde la red privada, que se conecta a PostgreSQL con su identidad de workload en vez de usuario y contraseña.",
        "en": "An HTTP container reachable only from the private network, connecting to PostgreSQL with its workload identity instead of a username and password."
      },
      "problem": {
        "es": "Necesitás publicar una API interna (consumida por otras apps o por usuarios en la red corporativa) con una base relacional detrás. Requisitos: sin IP pública en ningún componente, base de datos sin acceso público, sin contraseñas de aplicación guardadas en ningún lado (ni en variables, ni en secretos, ni en el estado de Terraform) y permisos mínimos para la identidad de la app.",
        "en": "You need to expose an internal API (consumed by other apps or by users on the corporate network) with a relational database behind it. Requirements: no public IP on any component, no public access to the database, no application passwords stored anywhere (not in variables, secrets or Terraform state) and least privilege for the app's identity."
      },
      "roles": [
        {
          "id": "client",
          "kind": "actor",
          "label": {
            "es": "Cliente en la red privada",
            "en": "Client on the private network"
          }
        },
        {
          "id": "ingress",
          "kind": "capability",
          "label": {
            "es": "Ingress interno",
            "en": "Internal ingress"
          },
          "capability": "internal-http-ingress"
        },
        {
          "id": "app",
          "kind": "capability",
          "label": {
            "es": "API en contenedor",
            "en": "Containerized API"
          },
          "capability": "container-platform"
        },
        {
          "id": "identity",
          "kind": "capability",
          "label": {
            "es": "Identidad sin secretos",
            "en": "Secretless identity"
          },
          "capability": "workload-identity"
        },
        {
          "id": "db-access",
          "kind": "capability",
          "label": {
            "es": "Acceso privado a la base",
            "en": "Private database access"
          },
          "capability": "private-service-access"
        },
        {
          "id": "database",
          "kind": "capability",
          "label": {
            "es": "PostgreSQL gestionado",
            "en": "Managed PostgreSQL"
          },
          "capability": "managed-postgres"
        }
      ],
      "edges": [
        {
          "from": "client",
          "to": "ingress",
          "label": {
            "es": "HTTPS",
            "en": "HTTPS"
          }
        },
        {
          "from": "ingress",
          "to": "app"
        },
        {
          "from": "app",
          "to": "identity",
          "label": {
            "es": "token",
            "en": "token"
          }
        },
        {
          "from": "app",
          "to": "db-access",
          "label": {
            "es": "PostgreSQL + TLS",
            "en": "PostgreSQL + TLS"
          }
        },
        {
          "from": "db-access",
          "to": "database"
        }
      ],
      "paths": [
        {
          "id": "request",
          "label": {
            "es": "Request",
            "en": "Request"
          },
          "roles": [
            "client",
            "ingress",
            "app"
          ]
        },
        {
          "id": "token",
          "label": {
            "es": "Token",
            "en": "Token"
          },
          "roles": [
            "app",
            "identity"
          ]
        },
        {
          "id": "query",
          "label": {
            "es": "Consulta a la base",
            "en": "Database query"
          },
          "roles": [
            "app",
            "db-access",
            "database"
          ]
        }
      ],
      "tags": [
        "containers",
        "postgres",
        "zero-trust",
        "passwordless",
        "private-networking"
      ],
      "legacy_unreviewed": true,
      "implementations": {
        "azure": {
          "provider": "azure",
          "bindings": {
            "ingress": {
              "service": {
                "es": "Container Apps environment interno",
                "en": "Internal Container Apps environment"
              },
              "config": {
                "es": "internal_load_balancer_enabled + zona DNS privada del default domain",
                "en": "internal_load_balancer_enabled + private DNS zone for the default domain"
              },
              "equivalence": "full"
            },
            "app": {
              "service": "Azure Container Apps",
              "config": {
                "es": "Workload profiles (Consumption), ingress limitado a la VNet",
                "en": "Workload profiles (Consumption), ingress limited to the VNet"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": "User-assigned Managed Identity",
              "config": {
                "es": "Rol en PostgreSQL creado con pgaadauth_create_principal",
                "en": "PostgreSQL role created with pgaadauth_create_principal"
              },
              "equivalence": "full"
            },
            "db-access": {
              "service": "Private Endpoint (subresource postgresqlServer)",
              "config": {
                "es": "+ zona privatelink.postgres.database.azure.com",
                "en": "+ privatelink.postgres.database.azure.com zone"
              },
              "equivalence": "full"
            },
            "database": {
              "service": "Azure Database for PostgreSQL – Flexible Server",
              "config": {
                "es": "Solo Entra ID (password_auth_enabled = false)",
                "en": "Entra ID only (password_auth_enabled = false)"
              },
              "equivalence": "full"
            }
          },
          "gotchas": [
            {
              "id": "aca-subnet-sizing",
              "severity": "networking",
              "title": {
                "es": "La subnet de ACA no se puede agrandar y su tamaño mínimo depende del tipo de environment",
                "en": "The ACA subnet can't be resized and its minimum size depends on the environment type"
              },
              "body": {
                "es": "Un environment con workload profiles (el default) necesita una subnet dedicada de al menos /27 delegada a Microsoft.App/environments; uno Consumption-only (legacy) necesita /23 y NO puede estar delegada. El tamaño no se puede cambiar después de crear el environment, y en despliegues single-revision el consumo de IPs se duplica temporalmente. Dimensioná pensando en el pico de réplicas, no en el día uno.",
                "en": "A workload profiles environment (the default) needs a dedicated subnet of at least /27 delegated to Microsoft.App/environments; a Consumption-only (legacy) one needs /23 and must NOT be delegated. The size can't be changed after the environment is created, and single-revision deployments temporarily double IP usage. Size for peak replicas, not day one."
              },
              "sources": [
                "https://learn.microsoft.com/azure/container-apps/custom-virtual-networks",
                "https://learn.microsoft.com/azure/container-apps/networking"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "app"
              ],
              "exams": [
                "AZ-104",
                "AZ-700"
              ]
            },
            {
              "id": "aca-internal-needs-private-dns",
              "severity": "networking",
              "title": {
                "es": "Un environment interno no resuelve su propio dominio desde la VNet",
                "en": "An internal environment doesn't resolve its own domain from the VNet"
              },
              "body": {
                "es": "Con el ILB, las apps quedan en <app>.<default_domain> pero nadie resuelve ese nombre hasta que creás una zona DNS privada con el nombre del default domain, registros A \"*\" y \"@\" al static IP del environment, y la vinculás a las VNets cliente. Sin eso, la API \"no existe\" aunque esté corriendo.",
                "en": "With the ILB, apps live at <app>.<default_domain> but nobody resolves that name until you create a private DNS zone named after the default domain, \"*\" and \"@\" A records pointing to the environment's static IP, and link it to the client VNets. Without it, the API \"doesn't exist\" even though it's running."
              },
              "sources": [
                "https://learn.microsoft.com/azure/container-apps/private-endpoints-with-dns",
                "https://learn.microsoft.com/azure/container-apps/waf-app-gateway"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "ingress"
              ],
              "exams": [
                "AZ-700"
              ]
            },
            {
              "id": "pg-network-mode-is-permanent",
              "severity": "networking",
              "title": {
                "es": "En PostgreSQL Flexible, VNet integration vs. Private Endpoint se elige una sola vez",
                "en": "In PostgreSQL Flexible, VNet integration vs. Private Endpoint is a one-time choice"
              },
              "body": {
                "es": "El modo de conectividad se define al crear el servidor y no se puede cambiar. Un servidor con VNet integration no admite Private Endpoints y no se puede mover a otra subnet o VNet. Este módulo usa modo \"acceso público\" con public_network_access_enabled = false + Private Endpoint, que es lo que permite consumirlo desde hub-and-spoke, peerings y on-premises con el mismo modelo que el resto de los PaaS.",
                "en": "The connectivity mode is set at server creation and can't be changed. A VNet-integrated server doesn't support Private Endpoints and can't be moved to another subnet or VNet. This module uses \"public access\" mode with public_network_access_enabled = false + Private Endpoint, which lets it be consumed from hub-and-spoke, peerings and on-premises with the same model as other PaaS services."
              },
              "sources": [
                "https://learn.microsoft.com/azure/postgresql/network/concepts-networking-private-link",
                "https://learn.microsoft.com/azure/postgresql/network/concepts-networking-private"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "db-access",
                "database"
              ],
              "exams": [
                "AZ-305",
                "AZ-700"
              ]
            },
            {
              "id": "pg-entra-app-role-needs-sql",
              "severity": "iam",
              "title": {
                "es": "Por ARM se agregan administradores Entra; el rol de la app se crea con SQL",
                "en": "ARM adds Entra admins; the app's role is created with SQL"
              },
              "body": {
                "es": "Desde el portal de Azure o la ARM API se agregan principals Entra como administradores. Con isAdmin=true, pgaadauth_create_principal les da azure_pg_admin, CREATEROLE y CREATEDB. Para que la identidad de la app sea un usuario común, un admin Entra puede ejecutar pgaadauth_create_principal('<nombre>', false, false). Para identidades administradas y service principals el nombre es el display name. El módulo deja el SQL exacto como output.",
                "en": "Entra principals are added as administrators from the Azure portal or the ARM API. With isAdmin=true, pgaadauth_create_principal gives them azure_pg_admin, CREATEROLE and CREATEDB. For the app identity to be a regular user, an Entra admin can run pgaadauth_create_principal('<name>', false, false). For managed identities and service principals the name is the display name. The module outputs the exact SQL."
              },
              "sources": [
                "https://learn.microsoft.com/azure/postgresql/security/security-manage-entra-users"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "database",
                "identity"
              ],
              "exams": [
                "AZ-305",
                "SC-300"
              ]
            },
            {
              "id": "pg-entra-token-resource",
              "severity": "iam",
              "title": {
                "es": "El token de Entra para PostgreSQL no se pide para el FQDN del servidor",
                "en": "The Entra token for PostgreSQL isn't requested for the server FQDN"
              },
              "body": {
                "es": "La contraseña es un access token cuyo resource es https://ossrdbms-aad.database.windows.net (en nube pública), no la URL del servidor. Es el error más común al migrar desde RDS, donde el \"token\" es una firma generada localmente con las credenciales IAM. Las librerías de Azure Identity lo resuelven si pedís el scope correcto.",
                "en": "The password is an access token whose resource is https://ossrdbms-aad.database.windows.net (public cloud), not the server URL. It's the most common mistake when migrating from RDS, where the \"token\" is a signature generated locally with IAM credentials. Azure Identity libraries handle it if you request the right scope."
              },
              "sources": [
                "https://learn.microsoft.com/azure/postgresql/security/security-entra-configure"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "identity",
                "database"
              ],
              "exams": [
                "SC-300"
              ]
            },
            {
              "id": "pg-stop-auto-restarts",
              "severity": "cost",
              "title": {
                "es": "Detener la base corta el cómputo, pero arranca sola a los 7 días",
                "en": "Stopping the database stops compute billing, but it restarts on its own after 7 days"
              },
              "body": {
                "es": "PostgreSQL Flexible factura cómputo mientras está encendido. Detenerlo corta el cobro de cómputo (no el de storage), pero el servicio lo vuelve a arrancar automáticamente a los 7 días. Para entornos de desarrollo, automatizá el stop en vez de confiar en un stop manual.",
                "en": "PostgreSQL Flexible bills compute while it's on. Stopping it stops compute billing (not storage), but the service automatically restarts it after 7 days. For dev environments, automate the stop instead of relying on a manual one."
              },
              "sources": [
                "https://learn.microsoft.com/azure/postgresql/configure-maintain/concepts-limits",
                "https://learn.microsoft.com/azure/postgresql/overview",
                "https://learn.microsoft.com/azure/postgresql/backup-restore/concepts-backup-restore"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "database"
              ],
              "exams": [
                "AZ-104"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "partial",
            "fixed_cost_drivers": [
              {
                "es": "PostgreSQL Flexible Server (cómputo + storage)",
                "en": "PostgreSQL Flexible Server (compute + storage)"
              },
              {
                "es": "Private Endpoint de la base (cargo por hora + GB)",
                "en": "Database Private Endpoint (hourly + GB)"
              },
              {
                "es": "Zonas DNS privadas",
                "en": "Private DNS zones"
              }
            ],
            "notes": {
              "es": "Container Apps (perfil Consumption) escala a cero y no tiene costo de ingress aparte: el ILB del environment está incluido. El piso lo pone la base de datos.",
              "en": "Container Apps (Consumption profile) scales to zero with no separate ingress cost: the environment's ILB is included. The floor is the database."
            }
          }
        },
        "aws": {
          "provider": "aws",
          "bindings": {
            "ingress": {
              "service": {
                "es": "Application Load Balancer interno",
                "en": "Internal Application Load Balancer"
              },
              "config": {
                "es": "Listener HTTPS con certificado de ACM",
                "en": "HTTPS listener with an ACM certificate"
              },
              "equivalence": "full"
            },
            "app": {
              "service": "Amazon ECS on Fargate",
              "config": {
                "es": "Subnets privadas, sin IP pública, sin NAT",
                "en": "Private subnets, no public IP, no NAT"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": "IAM Role (ECS task role)",
              "config": {
                "es": "rds-db:connect sobre un usuario de base concreto",
                "en": "rds-db:connect on one specific database user"
              },
              "equivalence": "full"
            },
            "db-access": {
              "service": {
                "es": "RDS en subnets privadas + security groups",
                "en": "RDS in private subnets + security groups"
              },
              "config": {
                "es": "Nativo de la VPC (no requiere endpoint)",
                "en": "VPC-native (no endpoint needed)"
              },
              "equivalence": "full"
            },
            "database": {
              "service": "Amazon RDS for PostgreSQL",
              "config": "iam_database_authentication_enabled",
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "El \"token\" de IAM es una firma generada localmente que dura 15 minutos y solo sirve para abrir la conexión, con un límite de conexiones nuevas por segundo.",
                  "en": "The IAM \"token\" is a locally generated signature that lasts 15 minutes and only opens the connection, with a new-connections-per-second limit."
                }
              }
            }
          },
          "gotchas": [
            {
              "id": "rds-iam-user-needs-sql",
              "severity": "iam",
              "title": {
                "es": "El usuario IAM de la base se crea con SQL, y con rds_iam deja de aceptar contraseña",
                "en": "The IAM database user is created with SQL, and with rds_iam it stops accepting passwords"
              },
              "body": {
                "es": "Habilitar IAM auth en la instancia no crea ningún usuario. Hay que ejecutar CREATE USER app; GRANT rds_iam TO app; y además los GRANT sobre tablas. En PostgreSQL, a un usuario con rds_iam (incluido el master) la autenticación IAM le gana a la contraseña: no se lo asignes al master si lo necesitás como break-glass.",
                "en": "Enabling IAM auth on the instance doesn't create any user. You must run CREATE USER app; GRANT rds_iam TO app; plus table GRANTs. In PostgreSQL, for a user with rds_iam (including master) IAM auth takes precedence over the password: don't grant it to master if you need it as break-glass."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html",
                "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.DBAccounts.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "database",
                "identity"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "rds-iam-token-lifetime",
              "severity": "reliability",
              "title": {
                "es": "El token de IAM auth dura 15 minutos y solo sirve para abrir la conexión",
                "en": "The IAM auth token lasts 15 minutes and is only used to open the connection"
              },
              "body": {
                "es": "El token vence a los 15 minutos y se usa solo para autenticar: no afecta la sesión una vez establecida, pero si intentás conectarte con un token vencido, la conexión se rechaza.",
                "en": "The token expires after 15 minutes and is used only to authenticate: it doesn't affect the session once established, but if you try to connect with an expired token, the connection is rejected."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html",
                "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.Connecting.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "identity",
                "database"
              ],
              "exams": [
                "SAA-C03"
              ]
            },
            {
              "id": "fargate-private-needs-endpoints",
              "severity": "cost",
              "title": {
                "es": "Fargate en subnet privada sin NAT necesita varios VPC endpoints pagos",
                "en": "Fargate in a private subnet without NAT needs several paid VPC endpoints"
              },
              "body": {
                "es": "Para bajar la imagen sin salida a internet, la tarea necesita interface endpoints de ECR (api y dkr), más el gateway endpoint de S3 (donde ECR guarda las capas). Si usa el driver de logs awslogs, también necesita el interface endpoint de CloudWatch Logs. Cada interface endpoint se cobra por hora por AZ. La alternativa (NAT Gateway) también tiene cargo por hora y por GB.",
                "en": "To pull the image without internet egress, the task needs ECR (api and dkr) interface endpoints, plus the S3 gateway endpoint (where ECR stores layers). If it uses the awslogs log driver, it also needs the CloudWatch Logs interface endpoint. Each interface endpoint is billed hourly per AZ. The alternative (NAT Gateway) is also billed hourly and per GB."
              },
              "sources": [
                "https://repost.aws/knowledge-center/ecs-fargate-tasks-private-subnet",
                "https://docs.aws.amazon.com/AmazonECR/latest/userguide/vpc-endpoints.html",
                "https://aws.amazon.com/privatelink/pricing/",
                "https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-pricing.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "app"
              ],
              "exams": [
                "SAA-C03",
                "ANS-C01"
              ]
            },
            {
              "id": "internal-alb-certificate",
              "severity": "security",
              "title": {
                "es": "HTTPS en un ALB interno igual necesita un certificado de servidor SSL",
                "en": "HTTPS on an internal ALB still needs an SSL server certificate"
              },
              "body": {
                "es": "Para crear un listener HTTPS hay que desplegar al menos un certificado de servidor SSL (de ACM o de IAM). ACM no puede validar recursos en una private hosted zone de VPC ni en ningún otro dominio privado, aunque el ALB sea interno. ACM no valida la propiedad del dominio en certificados importados ni firmados por una CA privada.",
                "en": "To create an HTTPS listener you must deploy at least one SSL server certificate (from ACM or IAM). ACM cannot validate resources in a VPC private hosted zone or any other private domain, even if the ALB is internal. ACM does not validate domain ownership for imported certificates or for certificates signed by a private CA."
              },
              "sources": [
                "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/create-https-listener.html",
                "https://docs.aws.amazon.com/acm/latest/userguide/domain-ownership-validation.html"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "ingress"
              ],
              "exams": [
                "SAA-C03",
                "SCS-C03"
              ]
            },
            {
              "id": "master-password-in-secrets-manager",
              "severity": "security",
              "title": {
                "es": "Que la contraseña del master user la gestione Secrets Manager",
                "en": "Let Secrets Manager manage the master user password"
              },
              "body": {
                "es": "Con manage_master_user_password = true, RDS genera la contraseña del master y la guarda en Secrets Manager. El provider de Terraform no permite combinarlo con password, y RDS no lo soporta en algunos escenarios (por ejemplo Blue/Green deployments), así que decidilo de entrada.",
                "en": "With manage_master_user_password = true, RDS generates the master password and stores it in Secrets Manager. The Terraform provider doesn't allow combining it with password, and RDS doesn't support it in some scenarios (for example Blue/Green deployments), so decide upfront."
              },
              "sources": [
                "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-secrets-manager.html",
                "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "database"
              ],
              "exams": [
                "SAA-C03",
                "SCS-C03"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "no",
            "fixed_cost_drivers": [
              {
                "es": "Application Load Balancer (cargo por hora + LCU)",
                "en": "Application Load Balancer (hourly + LCU)"
              },
              {
                "es": "Interface endpoints de ECR api, ECR dkr y CloudWatch Logs (por hora, por AZ)",
                "en": "ECR api, ECR dkr and CloudWatch Logs interface endpoints (hourly, per AZ)"
              },
              {
                "es": "RDS (instancia + storage)",
                "en": "RDS (instance + storage)"
              }
            ],
            "notes": {
              "es": "Es el patrón con más costo fijo de las tres nubes: ALB y endpoints cobran aunque no haya requests, y el servicio de ECS mantiene al menos una tarea corriendo.",
              "en": "It's the pattern with the highest fixed cost of the three clouds: ALB and endpoints bill with zero requests, and the ECS service keeps at least one task running."
            }
          }
        },
        "gcp": {
          "provider": "gcp",
          "bindings": {
            "ingress": {
              "service": "Cloud Run ingress \"internal\"",
              "config": "INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invoker",
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "\"Internal\" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.",
                  "en": "\"Internal\" is a network control, and Cloud Run also requires IAM (roles/run.invoker) by default: the client sends an ID token."
                },
                "aws": {
                  "es": "\"Internal\" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.",
                  "en": "\"Internal\" is a network control, and Cloud Run also requires IAM (roles/run.invoker) by default: the client sends an ID token."
                }
              }
            },
            "app": {
              "service": "Cloud Run (service)",
              "config": {
                "es": "Direct VPC egress hacia rangos privados",
                "en": "Direct VPC egress to private ranges"
              },
              "equivalence": "full"
            },
            "identity": {
              "service": {
                "es": "Service Account del servicio",
                "en": "The service's Service Account"
              },
              "config": "roles/cloudsql.client + roles/cloudsql.instanceUser",
              "equivalence": "full"
            },
            "db-access": {
              "service": "Private services access (VPC peering)",
              "config": "ipv4_enabled = false",
              "notes": {
                "es": "La IP privada llega por un VPC peering que no es transitivo; en hub-and-spoke conviene Private Service Connect.",
                "en": "The private IP comes over a VPC peering that isn't transitive; in hub-and-spoke, Private Service Connect is a better fit."
              },
              "equivalence": "partial",
              "from": {
                "azure": {
                  "es": "No es un Private Endpoint: es un VPC peering hacia la red de Google, que no es transitivo.",
                  "en": "It isn't a Private Endpoint: it's a VPC peering to Google's network, which isn't transitive."
                }
              }
            },
            "database": {
              "service": "Cloud SQL for PostgreSQL",
              "config": {
                "es": "cloudsql.iam_authentication = on + connector obligatorio",
                "en": "cloudsql.iam_authentication = on + connector required"
              },
              "equivalence": "full",
              "from": {
                "azure": {
                  "es": "El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin \".gserviceaccount.com\".",
                  "en": "The IAM user is created via API, without SQL, and a Service Account's user drops \".gserviceaccount.com\"."
                },
                "aws": {
                  "es": "El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin \".gserviceaccount.com\".",
                  "en": "The IAM user is created via API, without SQL, and a Service Account's user drops \".gserviceaccount.com\"."
                }
              }
            }
          },
          "gotchas": [
            {
              "id": "iam-sa-username-trim",
              "severity": "iam",
              "title": {
                "es": "En Postgres, el usuario IAM de una Service Account va sin \".gserviceaccount.com\"",
                "en": "In Postgres, a Service Account's IAM user drops \".gserviceaccount.com\""
              },
              "body": {
                "es": "El nombre del usuario de base es el email de la SA sin el sufijo .gserviceaccount.com (ej. api@proyecto.iam), por el límite de largo de nombres de PostgreSQL. Si lo creás con el email completo, el login falla con un error que parece de permisos.",
                "en": "The database user name is the SA email without the .gserviceaccount.com suffix (e.g. api@project.iam), due to PostgreSQL's name length limit. If you create it with the full email, login fails with an error that looks like a permissions problem."
              },
              "sources": [
                "https://docs.cloud.google.com/sql/docs/postgres/add-manage-iam-users",
                "https://docs.cloud.google.com/sql/docs/postgres/iam-logins"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "identity",
                "database"
              ],
              "exams": [
                "ACE",
                "PCA"
              ]
            },
            {
              "id": "iam-user-created-by-api",
              "severity": "iam",
              "title": {
                "es": "A diferencia de Azure y AWS, el login IAM se crea por API, sin SQL",
                "en": "Unlike Azure and AWS, the IAM login is created via API, without SQL"
              },
              "body": {
                "es": "google_sql_user con type CLOUD_IAM_SERVICE_ACCOUNT crea el usuario desde Terraform, y además necesitás roles/cloudsql.client y roles/cloudsql.instanceUser en la SA. Lo que sigue siendo SQL son los permisos sobre tablas (GRANT), igual que en las otras nubes.",
                "en": "google_sql_user with type CLOUD_IAM_SERVICE_ACCOUNT creates the user from Terraform, and you also need roles/cloudsql.client and roles/cloudsql.instanceUser on the SA. What remains SQL are table permissions (GRANT), same as on the other clouds."
              },
              "sources": [
                "https://docs.cloud.google.com/sql/docs/postgres/add-manage-iam-users",
                "https://docs.cloud.google.com/sql/docs/postgres/iam-overview"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "database",
                "identity"
              ],
              "exams": [
                "ACE",
                "PCSE"
              ]
            },
            {
              "id": "psa-peering-not-transitive",
              "severity": "networking",
              "title": {
                "es": "La IP privada de Cloud SQL viaja por un VPC peering que no es transitivo",
                "en": "Cloud SQL's private IP goes over a VPC peering that isn't transitive"
              },
              "body": {
                "es": "Private services access se implementa con VPC Network Peering hacia la red de Google. Solo permite conexiones desde una VPC, y el peering no ofrece ruteo transitivo: otra VPC peereada con la tuya no llega a la base. Si tu topología es hub-and-spoke, evaluá Private Service Connect en lugar de PSA.",
                "en": "Private services access is implemented with VPC Network Peering to Google's network. It only allows connections from one VPC, and peering doesn't provide transitive routing: another VPC peered with yours can't reach the database. If your topology is hub-and-spoke, consider Private Service Connect instead of PSA."
              },
              "sources": [
                "https://docs.cloud.google.com/sql/docs/postgres/private-ip",
                "https://docs.cloud.google.com/vpc/docs/vpc-peering"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "db-access"
              ],
              "exams": [
                "PCNE",
                "PCA"
              ]
            },
            {
              "id": "run-internal-is-not-auth",
              "severity": "iam",
              "title": {
                "es": "\"Internal\" es un control de red; la autenticación es IAM aparte",
                "en": "\"Internal\" is a network control; authentication is separate IAM"
              },
              "body": {
                "es": "Con ingress internal, Cloud Run solo acepta requests de fuentes como las VPCs del mismo proyecto, un perímetro de VPC SC o un internal Application Load Balancer, y de productos como Cloud Scheduler, Cloud Tasks, Eventarc, Pub/Sub o Workflows si están en el mismo proyecto o perímetro y usan la URL run.app por defecto. Las demás fuentes, incluida internet, no llegan. Pero la autenticación IAM sigue aplicando: el cliente tiene que presentar un ID token y su cuenta de servicio necesita roles/run.invoker.",
                "en": "With internal ingress, Cloud Run only accepts requests from sources such as VPCs in the same project, a VPC SC perimeter or an internal Application Load Balancer, and from products such as Cloud Scheduler, Cloud Tasks, Eventarc, Pub/Sub or Workflows if they're in the same project or perimeter and use the default run.app URL. Other sources, including the internet, can't reach it. But IAM authentication still applies: the client must present an ID token and its service account needs roles/run.invoker."
              },
              "sources": [
                "https://docs.cloud.google.com/run/docs/securing/ingress",
                "https://docs.cloud.google.com/run/docs/securing/managing-access",
                "https://docs.cloud.google.com/run/docs/authenticating/service-to-service"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "ingress",
                "app"
              ],
              "exams": [
                "PCSE",
                "PCA"
              ]
            },
            {
              "id": "connector-enforcement",
              "severity": "security",
              "title": {
                "es": "Forzá el uso de connectors para que nadie se conecte \"por fuera\"",
                "en": "Enforce connectors so nobody connects \"around\" them"
              },
              "body": {
                "es": "La autenticación IAM automática (sin manejar tokens a mano) la hacen Cloud SQL Auth Proxy o los Cloud SQL Language Connectors. connector_enforcement = REQUIRED rechaza toda conexión directa a la IP, así el único camino es con identidad y TLS gestionados.",
                "en": "Automatic IAM authentication (without handling tokens manually) is done by Cloud SQL Auth Proxy or the Cloud SQL Language Connectors. connector_enforcement = REQUIRED rejects every direct connection to the IP, so the only path is with managed identity and TLS."
              },
              "sources": [
                "https://docs.cloud.google.com/sql/docs/postgres/iam-logins",
                "https://docs.cloud.google.com/sql/docs/postgres/connect-connectors"
              ],
              "verified_at": "2026-10-06",
              "roles": [
                "db-access",
                "identity"
              ],
              "exams": [
                "PCSE"
              ]
            }
          ],
          "cost": {
            "scales_to_zero": "partial",
            "fixed_cost_drivers": [
              {
                "es": "Instancia de Cloud SQL (cómputo + storage)",
                "en": "Cloud SQL instance (compute + storage)"
              }
            ],
            "notes": {
              "es": "Cloud Run escala a cero y Direct VPC egress no tiene cargo propio (sí el tráfico). El piso es Cloud SQL, que factura mientras la instancia está activa.",
              "en": "Cloud Run scales to zero and Direct VPC egress has no charge of its own (traffic does). The floor is Cloud SQL, which bills while the instance is active."
            }
          }
        }
      },
      "terraform": {
        "azure": {
          "providers": {
            "azurerm": "~> 5.8"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "aws": {
          "providers": {
            "aws": "~> 6.67"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        },
        "gcp": {
          "providers": {
            "google": "~> 8.6"
          },
          "files": [
            "main.tf",
            "variables.tf",
            "outputs.tf",
            "versions.tf"
          ]
        }
      }
    }
  ]
}