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.

Microsoft Azure

Ingesta privada de objetos con expiración (TTL) — Azureobtiene tokenPUT objetoborra a los N díasrestringe origenWorkload productorIdentidad sin secretosUser-assigned ManagedIdentity2 gotchas en este rolAcceso privadoPrivate Endpoint(subresource blob)1 gotcha en este rolAlmacenamiento de objetosStorage Account(StorageV2) +container2 gotchas en este rolPolítica de expiraciónLifecycle managementpolicy3 gotchas en este rolPerímetro de datos ≈Storage firewall

Implementación

Identidad sin secretos
User-assigned Managed IdentityStorage Blob Data Contributor sobre el contenedorVenís de AWS: allá este rol es IAM Role (ECS task role). 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.Venís de GCP: allá este rol es Service Account adjunta al workload.
Acceso privado
Private Endpoint (subresource blob)+ zona privatelink.blob.core.windows.netVenís de AWS: allá este rol es Gateway VPC Endpoint para S3. 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.Venís de GCP: allá este rol es Private Google Access.
Almacenamiento de objetos
Storage Account (StorageV2) + containerShared Key deshabilitado, acceso público de red deshabilitadoVenís de AWS: allá este rol es S3 bucket.Venís de GCP: allá este rol es Cloud Storage bucket.
Política de expiración
Lifecycle management policydelete_after_days_since_creation_greater_thanVenís de AWS: allá este rol es S3 Lifecycle rule. El prefijo del filtro incluye el nombre del contenedor: un "incoming/" copiado de S3 no matchea nada y no da error.Venís de GCP: allá este rol es Object Lifecycle Management. 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.
Perímetro de datos
Storage firewall≈public_network_access = DisabledPara un perímetro a nivel organización entre varios PaaS, ver Network Security Perimeter.Venís de AWS: allá este rol es Bucket policy.Venís de GCP: allá este rol es Bucket IP filtering (opcional).

Modelo de costo

Escala a cero parcial

Costo fijo aunque no haya tráfico:

  • Private Endpoint (cargo por hora, más GB procesados)
  • Zona DNS privada (cargo mensual por zona)

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.

Piso mensual sin tráfico

USD 7,80/ mes

Con la configuración por default del módulo Terraform. Sirve para comparar órdenes de magnitud, no para presupuestar.

Precio de lista en eastus al 2026-10-24, en USD, sin descuentos ni impuestos.

Letra chica · 6 gotchas

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

Deshabilitar Shared Key obliga a que Terraform use Entra ID

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

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

Los role assignments son por cuenta o contenedor; por prefijo hacen falta condiciones ABAC

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

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

Private Endpoint sin zona DNS privada: el firewall del servicio puede responder 403

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.

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

Con soft delete activo, "borrar" no libera capacidad

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.

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

La policy tarda hasta 24 h en aplicarse y corre en background

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.

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

En Azure el prefijo del filtro incluye el nombre del contenedor

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.

⚑ Reportar desactualizado

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Ingesta privada de objetos con expiración (TTL) — Azure
#
# Storage Account sin claves (solo Entra ID), sin acceso público de red,
# alcanzable por Private Endpoint, con un contenedor de ingesta cuyo
# contenido se borra a los N días.

resource "azurerm_resource_group" "this" {
  name     = "rg-${var.name}"
  location = var.location
  tags     = var.tags
}

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

resource "azurerm_virtual_network" "this" {
  name                = "vnet-${var.name}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  address_space       = [var.vnet_address_space]
  tags                = var.tags
}

resource "azurerm_subnet" "endpoints" {
  name                 = "snet-private-endpoints"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [var.endpoints_subnet_prefix]
}

# Sin reglas propias: las default permiten tráfico intra-VNet y bloquean internet.
# Para que el NSG filtre tráfico hacia Private Endpoints hay que habilitar
# network policies en la subnet (private_endpoint_network_policies).
resource "azurerm_network_security_group" "endpoints" {
  name                = "nsg-${var.name}-endpoints"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  tags                = var.tags
}

resource "azurerm_subnet_network_security_group_association" "endpoints" {
  subnet_id                 = azurerm_subnet.endpoints.id
  network_security_group_id = azurerm_network_security_group.endpoints.id
}

# --- Storage ------------------------------------------------------------------

resource "azurerm_storage_account" "this" {
  #checkov:skip=CKV2_AZURE_1:CMK fuera de alcance del patrón; cifrado con claves de Microsoft + infraestructura.
  #checkov:skip=CKV_AZURE_206:ZRS es suficiente para datos efímeros con TTL; GRS duplica costo sin beneficio.
  #checkov:skip=CKV_AZURE_33:No se usa Queue Storage en este patrón.
  #checkov:skip=CKV_AZURE_59:checkov evalúa public_network_access_enabled (deprecado en azurerm 5); acá se usa public_network_access = "Disabled".
  #checkov:skip=CKV_AZURE_36:Sin bypass de servicios de confianza a propósito: el único camino es el Private Endpoint.
  name                     = var.storage_account_name
  resource_group_name      = azurerm_resource_group.this.name
  location                 = azurerm_resource_group.this.location
  account_kind             = "StorageV2"
  account_tier             = "Standard"
  account_replication_type = "ZRS"

  # Identidad: solo Entra ID. Sin Shared Key, sin SAS de cuenta, sin usuarios locales.
  shared_access_key_enabled       = false
  default_to_oauth_authentication = true
  local_user_enabled              = false

  # Red: sin endpoint público. El único camino es el Private Endpoint.
  public_network_access = "Disabled"

  allow_nested_items_to_be_public   = false
  cross_tenant_replication_enabled  = false
  https_traffic_only_enabled        = true
  min_tls_version                   = "TLS1_2"
  infrastructure_encryption_enabled = true

  network_rules {
    default_action = "Deny"
    bypass         = ["None"]
  }

  blob_properties {
    # Sin versioning: con versiones previas, lifecycle no borra la versión actual
    # hasta borrar las anteriores, y el TTL deja de ser predecible.
    versioning_enabled = false

    delete_retention_policy {
      days = var.soft_delete_retention_days
    }

    container_delete_retention_policy {
      days = 7
    }
  }

  tags = var.tags
}

resource "azurerm_storage_container" "ingest" {
  #checkov:skip=CKV2_AZURE_21:Logging de lectura vía diagnostic settings, fuera de alcance del patrón.
  # storage_account_id → se gestiona por Resource Manager (control plane),
  # así que funciona con public_network_access = "Disabled".
  name                  = "ingest"
  storage_account_id    = azurerm_storage_account.this.id
  container_access_type = "private"
}

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

resource "azurerm_storage_management_policy" "ttl" {
  storage_account_id = azurerm_storage_account.this.id

  rule {
    name    = "expire-ingest"
    enabled = true

    filters {
      blob_types = ["blockBlob"]
      # El prefijo DEBE empezar con el nombre del contenedor
      # (gotcha: prefix-includes-container).
      prefix_match = ["${azurerm_storage_container.ingest.name}/${var.object_prefix}"]
    }

    actions {
      base_blob {
        delete_after_days_since_creation_greater_than = var.ttl_days
      }
    }
  }
}

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

resource "azurerm_user_assigned_identity" "producer" {
  name                = "id-${var.name}-producer"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  tags                = var.tags
}

resource "azurerm_role_assignment" "producer_blob_writer" {
  # Scope mínimo posible sin ABAC: el contenedor (gotcha: rbac-scope-is-container).
  scope                = azurerm_storage_container.ingest.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azurerm_user_assigned_identity.producer.principal_id
  principal_type       = "ServicePrincipal"
}

# --- Private Endpoint + DNS ---------------------------------------------------

resource "azurerm_private_dns_zone" "blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.this.name
  tags                = var.tags
}

resource "azurerm_private_dns_zone_virtual_network_link" "blob" {
  name                 = "link-${var.name}"
  private_dns_zone_id  = azurerm_private_dns_zone.blob.id
  virtual_network_id   = azurerm_virtual_network.this.id
  registration_enabled = false
  tags                 = var.tags
}

resource "azurerm_private_endpoint" "blob" {
  name                = "pe-${var.name}-blob"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  subnet_id           = azurerm_subnet.endpoints.id

  private_service_connection {
    name                           = "psc-${var.name}-blob"
    private_connection_resource_id = azurerm_storage_account.this.id
    subresource_names              = ["blob"]
    is_manual_connection           = false
  }

  # Sin esto el FQDN resuelve a la IP pública y la request falla con 403
  # (gotcha: private-endpoint-needs-dns).
  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
  }

  tags = var.tags
}
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 "storage_account_name" {
  description = "Nombre global único de la Storage Account (3-24, solo minúsculas y números)."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9]{3,24}$", var.storage_account_name))
    error_message = "storage_account_name: 3-24 caracteres, solo minúsculas y números."
  }
}

variable "location" {
  description = "Región de Azure."
  type        = string
  default     = "eastus2"
}

variable "ttl_days" {
  description = "Días desde la creación tras los cuales se borra el blob."
  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 = "Ruta dentro del contenedor a la que aplica el TTL (sin el nombre del contenedor)."
  type        = string
  default     = "incoming/"
}

variable "soft_delete_retention_days" {
  description = "Retención de soft delete. El costo efectivo del TTL es ttl_days + este valor (gotcha: soft-delete-keeps-capacity)."
  type        = number
  default     = 7

  validation {
    condition     = var.soft_delete_retention_days >= 1 && var.soft_delete_retention_days <= 365
    error_message = "soft_delete_retention_days debe estar entre 1 y 365."
  }
}

variable "vnet_address_space" {
  description = "Espacio de direcciones de la VNet del workload."
  type        = string
  default     = "10.10.0.0/16"
}

variable "endpoints_subnet_prefix" {
  description = "Prefijo de la subnet para Private Endpoints."
  type        = string
  default     = "10.10.1.0/24"
}

variable "tags" {
  description = "Tags aplicados a todos los recursos."
  type        = map(string)
  default     = {}
}
outputs.tf
output "storage_account_id" {
  description = "ID de la Storage Account."
  value       = azurerm_storage_account.this.id
}

output "ingest_container_url" {
  description = "URL del contenedor de ingesta (resuelve a IP privada desde la VNet)."
  value       = "${azurerm_storage_account.this.primary_blob_endpoint}${azurerm_storage_container.ingest.name}"
}

output "producer_identity_client_id" {
  description = "Client ID de la Managed Identity que tiene que usar el workload productor."
  value       = azurerm_user_assigned_identity.producer.client_id
}
versions.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 5.8"
    }
  }
}

provider "azurerm" {
  features {}

  # Con shared_access_key_enabled = false el provider tiene que usar Entra ID
  # para las llamadas de data plane (gotcha: shared-key-needs-azuread-provider).
  storage_use_azuread = true

  # subscription_id se toma de ARM_SUBSCRIPTION_ID.
}

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
}