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.
Identidad sin secretos
AWS
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.
Sin letra chica en este rol.
Azure
User-assigned Managed Identity
Storage Blob Data Contributor sobre el contenedor
Vení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.
2 gotchas en este rol
Acceso privado
AWS
Gateway VPC Endpoint para S3≈ Equivalente parcial
asociado a la route table privada
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).
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.
1 gotcha en este rol
Azure
Private Endpoint (subresource blob)
+ zona privatelink.blob.core.windows.net
Vení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.
1 gotcha en este rol
Almacenamiento de objetos
AWS
S3 bucket
Block Public Access + BucketOwnerEnforced + SSE-KMS
Venís de Azure: allá este rol es Storage Account (StorageV2) + container.
Venís de GCP: allá este rol es Cloud Storage bucket.
2 gotchas en este rol
Azure
Storage Account (StorageV2) + container
Shared Key deshabilitado, acceso público de red deshabilitado
Venís de AWS: allá este rol es S3 bucket.
Venís de GCP: allá este rol es Cloud Storage bucket.
2 gotchas en este rol
Política de expiración
AWS
S3 Lifecycle rule
Expiration.Days + AbortIncompleteMultipartUpload
Vení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.
3 gotchas en este rol
Azure
Lifecycle management policy
delete_after_days_since_creation_greater_than
Vení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.
3 gotchas en este rol
Perímetro de datos
AWS
Bucket policy
Deny si aws:SourceVpce ≠ endpoint
Venís de Azure: allá este rol es Storage firewall.
Venís de GCP: allá este rol es Bucket IP filtering (opcional).
1 gotcha en este rol
Azure
Storage firewall≈ Equivalente parcial
public_network_access = Disabled
Para 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).
Sin letra chica en este rol.
Amazon Web Services
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
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.
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.
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.
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ó.
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.
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.
Terraform
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
}
}Microsoft Azure
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.
- Private Endpoint × 1 · 0.01 USD/hUSD 7,30
- Zona DNS privada × 1 · 0.5 USD/mesUSD 0,50
Precio de lista en eastus al 2026-10-24, en USD, sin descuentos ni impuestos.
Letra chica · 6 gotchas
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).
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).
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.
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.
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.
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.
Terraform
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.
}