ESEN

Ingesta privada de objetos con expiración (TTL)

Un workload sube archivos a un bucket privado, sin claves, por red privada, y los archivos se borran solos a los N días.

Problema

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.

La topología es la misma en todas las nubes; lo que cambia es con qué se implementa cada rol.

Google Cloud

Ingesta privada de objetos con expiración (TTL) — GCPobtiene tokenPUT objetoborra a los N díasrestringe origenWorkload productorIdentidad sin secretosService Accountadjunta al workload1 gotcha en este rolAcceso privado ≈Private Google Access1 gotcha en este rolAlmacenamiento de objetosCloud Storage bucket2 gotchas en este rolPolítica de expiraciónObject LifecycleManagement2 gotchas en este rolPerímetro de datos ≈Bucket IP filtering(opcional)1 gotcha en este rol

Implementación

Identidad sin secretos
Service Account adjunta al workloadroles/storage.objectCreator con IAM Condition por prefijoVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de AWS: allá este rol es IAM Role (ECS task role).
Acceso privado
Private Google Access≈flag en la subnetDa salida a las APIs de Google sin IP pública, pero no es un endpoint privado del bucket ni restringe quién accede.Venís de Azure: allá este rol es Private Endpoint (subresource blob). No es un Private Endpoint: no hay IP privada del bucket. Para restringir el origen hace falta bucket IP filtering o VPC Service Controls.Venís de AWS: allá este rol es Gateway VPC Endpoint para S3.
Almacenamiento de objetos
Cloud Storage bucketUniform bucket-level access + Public access prevention enforcedVenís de Azure: allá este rol es Storage Account (StorageV2) + container. 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.Venís de AWS: allá este rol es S3 bucket. 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.
Política de expiración
Object Lifecycle ManagementDelete por age + AbortIncompleteMultipartUploadVenís de Azure: allá este rol es Lifecycle management policy.Venís de AWS: allá este rol es S3 Lifecycle rule.
Perímetro de datos
Bucket IP filtering (opcional)≈El perímetro completo es VPC Service Controls, que se configura a nivel organización.Venís de Azure: allá este rol es Storage firewall.Venís de AWS: allá este rol es Bucket policy.

Modelo de costo

Escala a cero

Sin costo fijo

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.

Piso mensual sin tráfico

USD 0/ mes

USD 0: no hay componentes con costo fijo.

Letra chica · 5 gotchas

Seguridad● verificado hace 34 días◌ sin revisor externo

Uniform bucket-level access + public access prevention son el par a declarar

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).

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

roles/storage.objectCreator no alcanza para sobrescribir

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.

⚑ Reportar desactualizado
Red● verificado hace 34 días◌ sin revisor externo

Private Google Access da alcance a las APIs de Google; filtrar el origen del bucket es otro control

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.

⚑ Reportar desactualizado
Costo● verificado hace 34 días◌ sin revisor externo

Soft delete viene activo por default (7 días) y encarece los datos efímeros

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.

⚑ Reportar desactualizado

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Ingesta privada de objetos con expiración (TTL) — GCP
#
# Bucket con UBLA + public access prevention, soft delete explícito,
# acceso desde una subnet con Private Google Access, escritura acotada
# a un prefijo con IAM Conditions y borrado a los N días.

# --- Red ----------------------------------------------------------------------

resource "google_compute_network" "this" {
  name                    = "vpc-${var.name}"
  auto_create_subnetworks = false
}

# Denegar todo ingress explícitamente (las VPCs custom no traen reglas,
# pero declararlo deja la intención en el código).
resource "google_compute_firewall" "deny_all_ingress" {
  name      = "fw-${var.name}-deny-all-ingress"
  network   = google_compute_network.this.id
  direction = "INGRESS"
  priority  = 65534

  deny {
    protocol = "all"
  }

  source_ranges = ["0.0.0.0/0"]
}

resource "google_compute_subnetwork" "workloads" {
  name          = "snet-${var.name}-workloads"
  region        = var.region
  network       = google_compute_network.this.id
  ip_cidr_range = var.subnet_cidr

  # VMs/servicios sin IP pública llegan a storage.googleapis.com sin NAT.
  # No restringe quién accede al bucket (gotcha: pga-is-not-a-perimeter).
  private_ip_google_access = true

  log_config {
    aggregation_interval = "INTERVAL_10_MIN"
    flow_sampling        = 0.5
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

# --- Bucket -------------------------------------------------------------------

resource "google_storage_bucket" "ingest" {
  #checkov:skip=CKV_GCP_62:Access logs fuera de alcance; usar Cloud Audit Logs (Data Access) si se requiere auditoría.
  #checkov:skip=CKV_GCP_78:Sin versioning a propósito: el TTL es el comportamiento buscado.
  name          = var.bucket_name
  location      = upper(var.region)
  storage_class = "STANDARD"
  force_destroy = false
  labels        = var.labels

  # Equivalente a BucketOwnerEnforced + Block Public Access (gotcha: ubla-and-pap).
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning {
    enabled = false
  }

  # Default de GCS: 7 días. Acá es una decisión explícita (gotcha: soft-delete-default-7-days).
  soft_delete_policy {
    retention_duration_seconds = var.soft_delete_retention_seconds
  }

  lifecycle_rule {
    condition {
      age            = var.ttl_days
      matches_prefix = [var.object_prefix]
    }
    action {
      type = "Delete"
    }
  }

  lifecycle_rule {
    condition {
      age = 1
    }
    action {
      type = "AbortIncompleteMultipartUpload"
    }
  }

  dynamic "ip_filter" {
    for_each = var.restrict_to_vpc ? [1] : []
    content {
      mode = "Enabled"
      vpc_network_sources {
        network                = google_compute_network.this.id
        allowed_ip_cidr_ranges = [var.subnet_cidr]
      }
    }
  }
}

# --- Identidad del productor --------------------------------------------------

resource "google_service_account" "producer" {
  account_id   = "${var.name}-producer"
  display_name = "Productor de ingesta (${var.name})"
}

resource "google_storage_bucket_iam_member" "producer_create" {
  bucket = google_storage_bucket.ingest.name
  # objectCreator no puede sobrescribir (gotcha: objectcreator-cannot-overwrite).
  role   = "roles/storage.objectCreator"
  member = google_service_account.producer.member

  # Acotado al prefijo, como en S3. Requiere UBLA.
  condition {
    title       = "only-${trimsuffix(var.object_prefix, "/")}"
    description = "Solo objetos bajo ${var.object_prefix}"
    expression  = "resource.name.startsWith(\"projects/_/buckets/${var.bucket_name}/objects/${var.object_prefix}\")"
  }
}
variables.tf
variable "project_id" {
  description = "ID del proyecto de GCP."
  type        = string
}

variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"ingest-demo\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,19}$", var.name))
    error_message = "name: 3-20 caracteres, empieza con letra, minúsculas, números o guiones."
  }
}

variable "bucket_name" {
  description = "Nombre global único del bucket."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9_.-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "bucket_name: 3-63 caracteres, minúsculas, números, puntos, guiones o guiones bajos."
  }
}

variable "region" {
  description = "Región de GCP (el bucket se crea regional en la misma región)."
  type        = string
  default     = "us-central1"
}

variable "ttl_days" {
  description = "Edad en días tras la cual se borra el objeto."
  type        = number
  default     = 7

  validation {
    condition     = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
    error_message = "ttl_days debe ser un entero >= 1."
  }
}

variable "object_prefix" {
  description = "Prefijo de los objetos a los que aplica el TTL (en GCS el prefijo NO incluye el bucket)."
  type        = string
  default     = "incoming/"
}

variable "soft_delete_retention_seconds" {
  description = "Retención de soft delete. 0 lo deshabilita; el default de GCS es 7 días (gotcha: soft-delete-default-7-days)."
  type        = number
  default     = 0

  validation {
    condition     = var.soft_delete_retention_seconds == 0 || (var.soft_delete_retention_seconds >= 604800 && var.soft_delete_retention_seconds <= 7776000)
    error_message = "soft_delete_retention_seconds: 0 (deshabilitado) o entre 7 y 90 días (604800-7776000)."
  }
}

variable "subnet_cidr" {
  description = "CIDR de la subnet del workload."
  type        = string
  default     = "10.30.0.0/24"
}

variable "restrict_to_vpc" {
  description = "Activa bucket IP filtering para aceptar requests solo desde la VPC. Puede dejar afuera al pipeline si corre fuera de la VPC."
  type        = bool
  default     = false
}

variable "labels" {
  description = "Labels aplicados a los recursos que los soportan."
  type        = map(string)
  default     = {}
}
outputs.tf
output "bucket_url" {
  description = "URL gs:// del bucket."
  value       = google_storage_bucket.ingest.url
}

output "producer_service_account_email" {
  description = "Service Account que tiene que usar el workload productor."
  value       = google_service_account.producer.email
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.6"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

Amazon Web Services

Ingesta privada de objetos con expiración (TTL) — AWSobtiene tokenPUT objetoborra a los N díasrestringe origenWorkload productorIdentidad sin secretosIAM Role (ECS taskrole)Acceso privado ≈Gateway VPC Endpointpara S31 gotcha en este rolAlmacenamiento de objetosS3 bucket2 gotchas en este rolPolítica de expiraciónS3 Lifecycle rule3 gotchas en este rolPerímetro de datosBucket policy1 gotcha en este rol

Implementación

Identidad sin secretos
IAM Role (ECS task role)s3:PutObject acotado a bucket/incoming/*Venís de Azure: allá este rol es User-assigned Managed Identity.Venís de GCP: allá este rol es Service Account adjunta al workload.
Acceso privado
Gateway VPC Endpoint para S3≈asociado a la route table privadaEs una ruta en la route table, no una IP privada: no llega desde on-premises ni desde otra región (para eso, Interface Endpoint).Venís de Azure: allá este rol es Private Endpoint (subresource blob). 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.Venís de GCP: allá este rol es Private Google Access.
Almacenamiento de objetos
S3 bucketBlock Public Access + BucketOwnerEnforced + SSE-KMSVenís de Azure: allá este rol es Storage Account (StorageV2) + container.Venís de GCP: allá este rol es Cloud Storage bucket.
Política de expiración
S3 Lifecycle ruleExpiration.Days + AbortIncompleteMultipartUploadVenís de Azure: allá este rol es Lifecycle management policy. 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.Venís de GCP: allá este rol es Object Lifecycle Management.
Perímetro de datos
Bucket policyDeny si aws:SourceVpce ≠ endpointVenís de Azure: allá este rol es Storage firewall.Venís de GCP: allá este rol es Bucket IP filtering (opcional).

Modelo de costo

Escala a cero

Sin costo fijo

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.

Piso mensual sin tráfico

USD 0/ mes

USD 0: no hay componentes con costo fijo.

Letra chica · 6 gotchas

Seguridad● verificado hace 34 días◌ sin revisor externo

Block Public Access y ACLs deshabilitadas son default en buckets nuevos (pero declaralos igual)

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.

⚑ Reportar desactualizado
IAM● verificado hace 34 días◌ sin revisor externo

Un Deny por aws:SourceVpce también te deja afuera a vos

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.

⚑ Reportar desactualizado
Red● verificado hace 34 días◌ sin revisor externo

El Gateway Endpoint es gratis, pero solo sirve dentro de la VPC y la región

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.

⚑ Reportar desactualizado
Costo● verificado hace 34 días◌ sin revisor externo

La expiración es asincrónica, pero el cobro se corta en la fecha de expiración

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ó.

⚑ Reportar desactualizado
Costo● verificado hace 34 días◌ sin revisor externo

En un bucket versionado, Expiration no borra nada

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.

⚑ Reportar desactualizado
Costo● verificado hace 34 días◌ sin revisor externo

Los multipart uploads incompletos se siguen cobrando

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.

⚑ Reportar desactualizado

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Ingesta privada de objetos con expiración (TTL) — AWS
#
# Bucket S3 privado, cifrado con SSE-KMS, accesible por Gateway Endpoint,
# con escritura acotada a un prefijo y expiración a los N días.

# --- Red ----------------------------------------------------------------------

resource "aws_vpc" "this" {
  #checkov:skip=CKV2_AWS_11:Flow logs fuera de alcance del patrón (se cubren en el patrón de networking base).
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "vpc-${var.name}" }
}

# Sin reglas: nada usa el SG default (CIS 5.4).
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id

  tags = { Name = "rt-${var.name}-private" }
}

# Gateway endpoint: entrada en la route table, sin costo por hora ni por GB
# (gotcha: gateway-endpoint-scope).
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.this.id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]

  tags = { Name = "vpce-${var.name}-s3" }
}

# --- Bucket -------------------------------------------------------------------

resource "aws_s3_bucket" "ingest" {
  #checkov:skip=CKV_AWS_21:Sin versioning a propósito: con versioning, Expiration solo agrega un delete marker (gotcha: versioning-delete-marker).
  #checkov:skip=CKV_AWS_144:Replicación cross-region no aplica a datos efímeros con TTL.
  #checkov:skip=CKV_AWS_18:Access logging fuera de alcance; usar CloudTrail data events si se requiere auditoría.
  #checkov:skip=CKV2_AWS_62:Notificaciones de eventos fuera de alcance del patrón.
  bucket        = var.bucket_name
  force_destroy = false
}

# Default desde abril 2023 en buckets nuevos; se declara para evitar drift
# (gotcha: secure-defaults-since-2023).
resource "aws_s3_bucket_public_access_block" "ingest" {
  bucket                  = aws_s3_bucket.ingest.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_ownership_controls" "ingest" {
  bucket = aws_s3_bucket.ingest.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "ingest" {
  #checkov:skip=CKV2_AWS_67:Clave administrada por AWS (aws/s3): sin rotación manual que gestionar ni costo mensual.
  bucket = aws_s3_bucket.ingest.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    # Bucket Keys: menos llamadas a KMS (que se cobran por request).
    bucket_key_enabled = true
  }
}

# --- Expiración (TTL) ---------------------------------------------------------

resource "aws_s3_bucket_lifecycle_configuration" "ingest" {
  bucket = aws_s3_bucket.ingest.id

  rule {
    id     = "expire-incoming"
    status = "Enabled"

    filter {
      prefix = var.object_prefix
    }

    expiration {
      days = var.ttl_days
    }
  }

  # Las partes huérfanas se cobran y no se listan (gotcha: abort-incomplete-multipart).
  rule {
    id     = "abort-incomplete-multipart"
    status = "Enabled"

    filter {}

    abort_incomplete_multipart_upload {
      days_after_initiation = 1
    }
  }
}

# --- Perímetro: solo TLS y solo desde el VPC endpoint -------------------------

locals {
  object_arns = "${aws_s3_bucket.ingest.arn}/*"

  # Excluye principals de break-glass del Deny por endpoint. Si la lista está vacía
  # no se agrega la condición (un ArnNotLike vacío no excluye a nadie).
  vpce_deny_condition = merge(
    { StringNotEquals = { "aws:SourceVpce" = aws_vpc_endpoint.s3.id } },
    length(var.break_glass_principal_arns) > 0 ? { ArnNotLike = { "aws:PrincipalArn" = var.break_glass_principal_arns } } : {},
  )
}

resource "aws_s3_bucket_policy" "ingest" {
  bucket = aws_s3_bucket.ingest.id

  # La public access block tiene que existir antes que la policy.
  depends_on = [aws_s3_bucket_public_access_block.ingest]

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "DenyInsecureTransport"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource  = [aws_s3_bucket.ingest.arn, local.object_arns]
        Condition = { Bool = { "aws:SecureTransport" = "false" } }
      },
      {
        # Solo operaciones de objetos: la configuración del bucket sigue siendo
        # gestionable desde fuera de la VPC (gotcha: sourcevpce-lockout).
        Sid       = "DenyObjectAccessOutsideVpce"
        Effect    = "Deny"
        Principal = "*"
        Action    = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
        Resource  = local.object_arns
        Condition = local.vpce_deny_condition
      },
    ]
  })
}

# --- Identidad del productor --------------------------------------------------

resource "aws_iam_role" "producer" {
  name = "${var.name}-producer"

  # Ejemplo con ECS: cambiá el principal según dónde corra el workload
  # (ec2.amazonaws.com, lambda.amazonaws.com, o un OIDC provider para EKS).
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "producer_put" {
  name = "put-incoming-objects"
  role = aws_iam_role.producer.id

  # Acotado al prefijo: el equivalente de Azure necesita ABAC (gotcha: rbac-scope-is-container).
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:PutObject"]
      Resource = "${aws_s3_bucket.ingest.arn}/${var.object_prefix}*"
    }]
  })
}
variables.tf
variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"ingest-demo\")."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.name))
    error_message = "name: 3-20 caracteres, minúsculas, números o guiones."
  }
}

variable "bucket_name" {
  description = "Nombre global único del bucket S3."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "bucket_name: 3-63 caracteres, minúsculas, números, puntos o guiones."
  }
}

variable "region" {
  description = "Región de AWS."
  type        = string
  default     = "us-east-1"
}

variable "ttl_days" {
  description = "Días desde la creación tras los cuales expira el objeto."
  type        = number
  default     = 7

  validation {
    condition     = var.ttl_days >= 1 && floor(var.ttl_days) == var.ttl_days
    error_message = "ttl_days debe ser un entero >= 1."
  }
}

variable "object_prefix" {
  description = "Prefijo de las keys a las que aplica el TTL (en S3 el prefijo NO incluye el bucket)."
  type        = string
  default     = "incoming/"
}

variable "vpc_cidr" {
  description = "CIDR de la VPC del workload."
  type        = string
  default     = "10.20.0.0/16"
}

variable "break_glass_principal_arns" {
  description = "ARNs (admite comodines) excluidos del Deny por VPC endpoint: consola, CI. Gotcha: sourcevpce-lockout."
  type        = list(string)
  default     = []
}

variable "tags" {
  description = "Tags aplicados a todos los recursos."
  type        = map(string)
  default     = {}
}
outputs.tf
output "bucket_arn" {
  description = "ARN del bucket."
  value       = aws_s3_bucket.ingest.arn
}

output "vpc_endpoint_id" {
  description = "ID del Gateway Endpoint (usado en la condición aws:SourceVpce)."
  value       = aws_vpc_endpoint.s3.id
}

output "producer_role_arn" {
  description = "ARN del rol que tiene que asumir el workload productor."
  value       = aws_iam_role.producer.arn
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.67"
    }
  }
}

provider "aws" {
  region = var.region

  default_tags {
    tags = var.tags
  }
}