ESEN

Web API privada Zero Trust con PostgreSQL sin contraseñas

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.

Problema

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.

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

Google Cloud

Web API privada Zero Trust con PostgreSQL sin contraseñas — GCPHTTPStokenPostgreSQL + TLSCliente en la red privadaIngress internoCloud Run ingress"internal"1 gotcha en este rolAPI en contenedorCloud Run (service)1 gotcha en este rolIdentidad sin secretosService Account delservicio3 gotchas en este rolAcceso privado a la base ≈Private servicesaccess (VPC peering)2 gotchas en este rolPostgreSQL gestionadoCloud SQL forPostgreSQL2 gotchas en este rol

Implementación

Ingress interno
Cloud Run ingress "internal"INGRESS_TRAFFIC_INTERNAL_ONLY + roles/run.invokerVenís de Azure: allá este rol es Container Apps environment interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.Venís de AWS: allá este rol es Application Load Balancer interno. "Internal" es un control de red, y además Cloud Run exige IAM (roles/run.invoker) por default: el cliente manda un ID token.
API en contenedor
Cloud Run (service)Direct VPC egress hacia rangos privadosVenís de Azure: allá este rol es Azure Container Apps.Venís de AWS: allá este rol es Amazon ECS on Fargate.
Identidad sin secretos
Service Account del servicioroles/cloudsql.client + roles/cloudsql.instanceUserVení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 a la base
Private services access (VPC peering)≈ipv4_enabled = falseLa IP privada llega por un VPC peering que no es transitivo; en hub-and-spoke conviene Private Service Connect.Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer). No es un Private Endpoint: es un VPC peering hacia la red de Google, que no es transitivo.Venís de AWS: allá este rol es RDS en subnets privadas + security groups.
PostgreSQL gestionado
Cloud SQL for PostgreSQLcloudsql.iam_authentication = on + connector obligatorioVenís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".Venís de AWS: allá este rol es Amazon RDS for PostgreSQL. El usuario IAM se crea por API, sin SQL, y el de una Service Account va sin ".gserviceaccount.com".

Modelo de costo

Escala a cero parcial

Costo fijo aunque no haya tráfico:

  • Instancia de Cloud SQL (cómputo + storage)

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.

Piso mensual sin tráfico

USD 51,01/ mes

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

Precio de lista en us-east1 al 2026-10-06, en USD, sin descuentos ni impuestos.

Algún precio se cargó a mano desde la página oficial: la API de esa nube pide key.

⚖ Comparar SKUs: PostgreSQL administrado →

Letra chica · 5 gotchas

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

Forzá el uso de connectors para que nadie se conecte "por fuera"

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.

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

En Postgres, el usuario IAM de una Service Account va sin ".gserviceaccount.com"

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.

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

A diferencia de Azure y AWS, el login IAM se crea por API, sin SQL

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.

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

"Internal" es un control de red; la autenticación es IAM aparte

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.

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

La IP privada de Cloud SQL viaja por un VPC peering que no es transitivo

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.

⚑ Reportar desactualizado

Terraform

google ~> 8.6✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Web API privada Zero Trust — GCP
#
# Cloud Run con ingress interno e IAM invoker, saliendo por Direct VPC egress
# hacia Cloud SQL for PostgreSQL con IP privada (PSA) y autenticación IAM.

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

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

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" "run" {
  name                     = "snet-${var.name}-run"
  region                   = var.region
  network                  = google_compute_network.this.id
  ip_cidr_range            = var.subnet_cidr
  private_ip_google_access = true

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

# Private services access: peering hacia la red de Google (gotcha: psa-peering-not-transitive).
resource "google_compute_global_address" "psa" {
  name          = "psa-${var.name}"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = var.psa_prefix_length
  network       = google_compute_network.this.id
}

resource "google_service_networking_connection" "psa" {
  network                 = google_compute_network.this.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.psa.name]
}

# --- Cloud SQL (solo IP privada, IAM auth, connectors obligatorios) -----------

resource "google_sql_database_instance" "this" {
  #checkov:skip=CKV_GCP_110:pgAudit agrega volumen y costo de logs; habilitar si hay requisito de auditoría.
  #checkov:skip=CKV_GCP_79:Major version alineada entre las tres nubes del patrón (PostgreSQL 17); se sube en conjunto.
  #checkov:skip=CKV2_GCP_13:log_duration en todas las sentencias es ruidoso; se usa log_min_duration_statement.
  name                = "pg-${var.name}"
  region              = var.region
  database_version    = "POSTGRES_17"
  deletion_protection = var.deletion_protection

  settings {
    edition           = "ENTERPRISE"
    tier              = var.db_tier
    availability_type = "ZONAL"

    # Solo connectors (Auth Proxy / Language Connectors) — gotcha: connector-enforcement.
    connector_enforcement = "REQUIRED"

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.this.id
      # Los connectors usan mTLS con certificados efímeros, así que se puede exigir
      # certificado de cliente sin distribuir certificados a mano.
      ssl_mode = "TRUSTED_CLIENT_CERTIFICATE_REQUIRED"
    }

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
    }

    database_flags {
      name  = "cloudsql.iam_authentication"
      value = "on"
    }
    database_flags {
      name  = "log_connections"
      value = "on"
    }
    database_flags {
      name  = "log_disconnections"
      value = "on"
    }
    database_flags {
      name  = "log_checkpoints"
      value = "on"
    }
    database_flags {
      name  = "log_lock_waits"
      value = "on"
    }
    database_flags {
      name  = "log_temp_files"
      value = "0"
    }
    database_flags {
      name  = "log_min_duration_statement"
      value = "-1"
    }
    database_flags {
      name  = "log_hostname"
      value = "on"
    }
    database_flags {
      name  = "log_min_messages"
      value = "error"
    }
    database_flags {
      name  = "log_min_error_statement"
      value = "error"
    }
    database_flags {
      name  = "log_statement"
      value = "ddl"
    }

    user_labels = var.labels
  }

  depends_on = [google_service_networking_connection.psa]
}

resource "google_sql_database" "app" {
  name     = var.database_name
  instance = google_sql_database_instance.this.name
}

# --- Identidad de la API --------------------------------------------------------

resource "google_service_account" "api" {
  account_id   = "${var.name}-api"
  display_name = "API privada (${var.name})"
}

resource "google_project_iam_member" "api" {
  for_each = toset(["roles/cloudsql.client", "roles/cloudsql.instanceUser"])
  project  = var.project_id
  role     = each.value
  member   = google_service_account.api.member
}

# Login IAM creado por API, sin SQL. El nombre va SIN ".gserviceaccount.com"
# (gotchas: iam-sa-username-trim, iam-user-created-by-api).
resource "google_sql_user" "api" {
  name     = trimsuffix(google_service_account.api.email, ".gserviceaccount.com")
  instance = google_sql_database_instance.this.name
  type     = "CLOUD_IAM_SERVICE_ACCOUNT"
}

# --- Cloud Run (ingress interno + IAM invoker) --------------------------------

resource "google_cloud_run_v2_service" "api" {
  name                = "${var.name}-api"
  location            = var.region
  ingress             = "INGRESS_TRAFFIC_INTERNAL_ONLY"
  deletion_protection = var.deletion_protection
  labels              = var.labels

  template {
    service_account = google_service_account.api.email

    scaling {
      min_instance_count = 0
      max_instance_count = var.max_instances
    }

    vpc_access {
      egress = "PRIVATE_RANGES_ONLY"
      network_interfaces {
        network    = google_compute_network.this.id
        subnetwork = google_compute_subnetwork.run.id
      }
    }

    containers {
      image = var.container_image

      ports {
        container_port = var.container_port
      }

      # La app usa el Cloud SQL Language Connector con IAM auth automática:
      # no hay contraseña ni token manual.
      env {
        name  = "INSTANCE_CONNECTION_NAME"
        value = google_sql_database_instance.this.connection_name
      }
      env {
        name  = "DB_IAM_USER"
        value = google_sql_user.api.name
      }
      env {
        name  = "DB_NAME"
        value = var.database_name
      }
      env {
        name  = "DB_IP_TYPE"
        value = "PRIVATE"
      }
    }
  }
}

# Sin allUsers: cada caller necesita roles/run.invoker (validado en la variable).
resource "google_cloud_run_v2_service_iam_member" "invoker" {
  for_each = var.invoker_members
  name     = google_cloud_run_v2_service.api.name
  location = google_cloud_run_v2_service.api.location
  role     = "roles/run.invoker"
  member   = each.value
}
variables.tf
variable "project_id" {
  description = "ID del proyecto de GCP."
  type        = string
}

variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"api-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 "region" {
  description = "Región de GCP."
  type        = string
  default     = "us-central1"
}

variable "subnet_cidr" {
  description = "CIDR de la subnet usada por Direct VPC egress."
  type        = string
  default     = "10.60.0.0/24"
}

variable "psa_prefix_length" {
  description = "Tamaño del rango reservado para private services access (Cloud SQL)."
  type        = number
  default     = 20
}

variable "container_image" {
  description = "Imagen de la API (Artifact Registry)."
  type        = string
}

variable "container_port" {
  description = "Puerto en el que escucha la API."
  type        = number
  default     = 8080
}

variable "max_instances" {
  description = "Máximo de instancias de Cloud Run."
  type        = number
  default     = 10
}

variable "invoker_members" {
  description = "Principals con roles/run.invoker (ej. \"serviceAccount:caller@proyecto.iam.gserviceaccount.com\"). Gotcha: run-internal-is-not-auth."
  type        = set(string)
  default     = []

  validation {
    condition     = alltrue([for m in var.invoker_members : !contains(["allUsers", "allAuthenticatedUsers"], m)])
    error_message = "Una API privada no puede ser invocable por allUsers ni allAuthenticatedUsers."
  }
}

variable "db_tier" {
  description = "Tier de Cloud SQL (edición Enterprise)."
  type        = string
  default     = "db-custom-1-3840"
}

variable "database_name" {
  description = "Base de datos de la aplicación."
  type        = string
  default     = "app"
}

variable "deletion_protection" {
  description = "Protección contra borrado de Cloud SQL y Cloud Run."
  type        = bool
  default     = true
}

variable "labels" {
  description = "Labels aplicados a los recursos que los soportan."
  type        = map(string)
  default     = {}
}
outputs.tf
output "service_uri" {
  description = "URL run.app del servicio (solo alcanzable con ingress interno + ID token)."
  value       = google_cloud_run_v2_service.api.uri
}

output "instance_connection_name" {
  description = "Connection name de Cloud SQL para el connector."
  value       = google_sql_database_instance.this.connection_name
}

output "db_iam_user" {
  description = "Usuario de base de la Service Account (sin .gserviceaccount.com)."
  value       = google_sql_user.api.name
}

output "app_grants_sql" {
  description = "Permisos sobre tablas: siguen siendo SQL (gotcha: iam-user-created-by-api)."
  value       = <<-SQL
    -- Conectado a la base '${var.database_name}' como un usuario con privilegios:
    GRANT USAGE ON SCHEMA public TO "${google_sql_user.api.name}";
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${google_sql_user.api.name}";
  SQL
}
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

Web API privada Zero Trust con PostgreSQL sin contraseñas — AWSHTTPStokenPostgreSQL + TLSCliente en la red privadaIngress internoApplication LoadBalancer interno1 gotcha en este rolAPI en contenedorAmazon ECS on Fargate1 gotcha en este rolIdentidad sin secretosIAM Role (ECS taskrole)2 gotchas en este rolAcceso privado a la baseRDS en subnetsprivadas + securitygroupsPostgreSQL gestionadoAmazon RDS forPostgreSQL3 gotchas en este rol

Implementación

Ingress interno
Application Load Balancer internoListener HTTPS con certificado de ACMVenís de Azure: allá este rol es Container Apps environment interno.Venís de GCP: allá este rol es Cloud Run ingress "internal".
API en contenedor
Amazon ECS on FargateSubnets privadas, sin IP pública, sin NATVenís de Azure: allá este rol es Azure Container Apps.Venís de GCP: allá este rol es Cloud Run (service).
Identidad sin secretos
IAM Role (ECS task role)rds-db:connect sobre un usuario de base concretoVenís de Azure: allá este rol es User-assigned Managed Identity.Venís de GCP: allá este rol es Service Account del servicio.
Acceso privado a la base
RDS en subnets privadas + security groupsNativo de la VPC (no requiere endpoint)Venís de Azure: allá este rol es Private Endpoint (subresource postgresqlServer).Venís de GCP: allá este rol es Private services access (VPC peering).
PostgreSQL gestionado
Amazon RDS for PostgreSQLiam_database_authentication_enabledVenís de Azure: allá este rol es Azure Database for PostgreSQL – Flexible Server. 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.Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.

Modelo de costo

No escala a cero

Costo fijo aunque no haya tráfico:

  • Application Load Balancer (cargo por hora + LCU)
  • Interface endpoints de ECR api, ECR dkr y CloudWatch Logs (por hora, por AZ)
  • RDS (instancia + storage)

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.

Piso mensual sin tráfico

USD 74,21/ mes

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

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

⚖ Comparar SKUs: PostgreSQL administrado →

Letra chica · 5 gotchas

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

HTTPS en un ALB interno igual necesita un certificado de servidor SSL

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.

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

Que la contraseña del master user la gestione Secrets Manager

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.

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

El usuario IAM de la base se crea con SQL, y con rds_iam deja de aceptar contraseña

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.

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

Fargate en subnet privada sin NAT necesita varios VPC endpoints pagos

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.

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

El token de IAM auth dura 15 minutos y solo sirve para abrir la conexión

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.

⚑ Reportar desactualizado

Terraform

aws ~> 6.67✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Web API privada Zero Trust — AWS
#
# ECS Fargate en subnets privadas (sin NAT) detrás de un ALB interno HTTPS,
# conectándose a RDS for PostgreSQL con IAM database authentication.

data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}

locals {
  private_subnets = { for i, az in var.availability_zones : az => cidrsubnet(var.vpc_cidr, 8, i) }

  # Endpoints que necesita Fargate sin salida a internet (gotcha: fargate-private-needs-endpoints).
  interface_endpoints = toset(["ecr.api", "ecr.dkr", "logs"])

  # Sin contraseña: la app genera el token con las credenciales del task role
  # (gotcha: rds-iam-token-lifetime → usar pool de conexiones).
  app_environment = [
    { name = "PGHOST", value = aws_db_instance.this.address },
    { name = "PGPORT", value = "5432" },
    { name = "PGUSER", value = var.db_app_user },
    { name = "PGDATABASE", value = var.database_name },
    { name = "PGSSLMODE", value = "verify-full" },
    { name = "AWS_REGION", value = var.region },
  ]
}

# --- 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}" }
}

resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
}

resource "aws_subnet" "private" {
  for_each          = local.private_subnets
  vpc_id            = aws_vpc.this.id
  availability_zone = each.key
  cidr_block        = each.value

  tags = { Name = "snet-${var.name}-${each.key}" }
}

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

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

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}

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" }
}

resource "aws_vpc_endpoint" "interface" {
  for_each            = local.interface_endpoints
  vpc_id              = aws_vpc.this.id
  service_name        = "com.amazonaws.${var.region}.${each.key}"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = true
  subnet_ids          = [for s in aws_subnet.private : s.id]
  security_group_ids  = [aws_security_group.endpoints.id]

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

# --- Security groups (mínimo privilegio entre capas) --------------------------

resource "aws_security_group" "alb" {
  name        = "${var.name}-alb"
  description = "ALB interno de la API"
  vpc_id      = aws_vpc.this.id
}

resource "aws_security_group" "app" {
  name        = "${var.name}-app"
  description = "Tareas de ECS de la API"
  vpc_id      = aws_vpc.this.id
}

resource "aws_security_group" "db" {
  name        = "${var.name}-db"
  description = "RDS PostgreSQL"
  vpc_id      = aws_vpc.this.id
}

resource "aws_security_group" "endpoints" {
  name        = "${var.name}-endpoints"
  description = "Interface VPC endpoints"
  vpc_id      = aws_vpc.this.id
}

resource "aws_vpc_security_group_ingress_rule" "alb_from_clients" {
  for_each          = toset(var.client_cidrs)
  security_group_id = aws_security_group.alb.id
  description       = "HTTPS desde la red privada"
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
  cidr_ipv4         = each.value
}

resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
  security_group_id            = aws_security_group.alb.id
  description                  = "Hacia las tareas"
  ip_protocol                  = "tcp"
  from_port                    = var.container_port
  to_port                      = var.container_port
  referenced_security_group_id = aws_security_group.app.id
}

resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
  security_group_id            = aws_security_group.app.id
  description                  = "Desde el ALB"
  ip_protocol                  = "tcp"
  from_port                    = var.container_port
  to_port                      = var.container_port
  referenced_security_group_id = aws_security_group.alb.id
}

resource "aws_vpc_security_group_egress_rule" "app_to_db" {
  security_group_id            = aws_security_group.app.id
  description                  = "PostgreSQL"
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = aws_security_group.db.id
}

resource "aws_vpc_security_group_egress_rule" "app_to_endpoints" {
  security_group_id            = aws_security_group.app.id
  description                  = "ECR y CloudWatch Logs por interface endpoints"
  ip_protocol                  = "tcp"
  from_port                    = 443
  to_port                      = 443
  referenced_security_group_id = aws_security_group.endpoints.id
}

resource "aws_vpc_security_group_egress_rule" "app_to_s3" {
  security_group_id = aws_security_group.app.id
  description       = "Capas de imagen de ECR en S3 (gateway endpoint)"
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
  prefix_list_id    = aws_vpc_endpoint.s3.prefix_list_id
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  description                  = "PostgreSQL desde la API"
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = aws_security_group.app.id
}

resource "aws_vpc_security_group_ingress_rule" "endpoints_from_app" {
  security_group_id            = aws_security_group.endpoints.id
  description                  = "HTTPS desde las tareas"
  ip_protocol                  = "tcp"
  from_port                    = 443
  to_port                      = 443
  referenced_security_group_id = aws_security_group.app.id
}

# --- ALB interno --------------------------------------------------------------

resource "aws_lb" "this" {
  #checkov:skip=CKV_AWS_91:Access logs fuera de alcance; requieren un bucket S3 dedicado.
  #checkov:skip=CKV2_AWS_28:WAF opcional en un ALB interno; evaluar según el modelo de amenazas.
  name                       = "${var.name}-alb"
  internal                   = true
  load_balancer_type         = "application"
  subnets                    = [for s in aws_subnet.private : s.id]
  security_groups            = [aws_security_group.alb.id]
  drop_invalid_header_fields = true
  enable_deletion_protection = true
}

resource "aws_lb_target_group" "api" {
  #checkov:skip=CKV_AWS_378:TLS termina en el ALB; el tramo ALB→tarea queda dentro de la VPC y acotado por security groups.
  name        = "${var.name}-api"
  port        = var.container_port
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_vpc.this.id

  health_check {
    path    = var.health_check_path
    matcher = "200"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.this.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
}

# --- ECS Fargate --------------------------------------------------------------

resource "aws_ecs_cluster" "this" {
  name = "${var.name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_cloudwatch_log_group" "api" {
  #checkov:skip=CKV_AWS_158:Cifrado con la clave administrada por CloudWatch; CMK opcional.
  name              = "/ecs/${var.name}-api"
  retention_in_days = 365
}

resource "aws_iam_role" "execution" {
  name = "${var.name}-ecs-execution"
  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_attachment" "execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role" "task" {
  name = "${var.name}-api-task"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [{ Effect = "Allow", Principal = { Service = "ecs-tasks.amazonaws.com" }, Action = "sts:AssumeRole" }]
  })
}

# Permiso para generar el token de UN usuario de base, en UNA instancia.
resource "aws_iam_role_policy" "task_rds_connect" {
  name = "rds-iam-connect"
  role = aws_iam_role.task.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "rds-db:connect"
      Resource = "arn:${data.aws_partition.current.partition}:rds-db:${var.region}:${data.aws_caller_identity.current.account_id}:dbuser:${aws_db_instance.this.resource_id}/${var.db_app_user}"
    }]
  })
}

resource "aws_ecs_task_definition" "api" {
  family                   = "${var.name}-api"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name                   = "api"
    image                  = var.container_image
    essential              = true
    readonlyRootFilesystem = true
    portMappings           = [{ containerPort = var.container_port, protocol = "tcp" }]
    environment            = local.app_environment
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.api.name
        awslogs-region        = var.region
        awslogs-stream-prefix = "api"
      }
    }
  }])
}

resource "aws_ecs_service" "api" {
  name            = "${var.name}-api"
  cluster         = aws_ecs_cluster.this.id
  task_definition = aws_ecs_task_definition.api.arn
  launch_type     = "FARGATE"
  desired_count   = var.desired_count

  network_configuration {
    subnets          = [for s in aws_subnet.private : s.id]
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.api.arn
    container_name   = "api"
    container_port   = var.container_port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  depends_on = [aws_lb_listener.https, aws_vpc_endpoint.interface]
}

# --- RDS PostgreSQL con IAM auth ----------------------------------------------

resource "aws_db_subnet_group" "this" {
  name       = "${var.name}-db"
  subnet_ids = [for s in aws_subnet.private : s.id]
}

resource "aws_db_instance" "this" {
  #checkov:skip=CKV_AWS_157:Multi-AZ configurable (db_multi_az); desactivado por default por costo.
  #checkov:skip=CKV_AWS_118:Enhanced monitoring requiere un rol extra; Performance Insights cubre el diagnóstico básico.
  #checkov:skip=CKV_AWS_354:Performance Insights con la clave administrada por AWS.
  #checkov:skip=CKV2_AWS_30:Logging de queries vía parameter group, fuera de alcance del patrón.
  identifier                          = "${var.name}-db"
  engine                              = "postgres"
  engine_version                      = "17"
  instance_class                      = var.db_instance_class
  allocated_storage                   = 20
  storage_encrypted                   = true
  db_name                             = var.database_name
  db_subnet_group_name                = aws_db_subnet_group.this.name
  vpc_security_group_ids              = [aws_security_group.db.id]
  publicly_accessible                 = false
  multi_az                            = var.db_multi_az
  iam_database_authentication_enabled = true

  # Master user siempre existe: que la contraseña la genere y guarde Secrets Manager
  # (gotcha: master-password-in-secrets-manager).
  username                    = "dbadmin"
  manage_master_user_password = true

  backup_retention_period         = 7
  copy_tags_to_snapshot           = true
  deletion_protection             = true
  final_snapshot_identifier       = "${var.name}-db-final"
  auto_minor_version_upgrade      = true
  performance_insights_enabled    = true
  enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
}
variables.tf
variable "name" {
  description = "Prefijo para nombrar los recursos (ej. \"api-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 "region" {
  description = "Región de AWS."
  type        = string
  default     = "us-east-1"
}

variable "availability_zones" {
  description = "Dos AZs para las subnets privadas (RDS exige un subnet group en al menos dos)."
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]

  validation {
    condition     = length(var.availability_zones) == 2
    error_message = "Indicá exactamente dos AZs."
  }
}

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

variable "client_cidrs" {
  description = "CIDRs que pueden llamar a la API (red corporativa, VPCs peereadas)."
  type        = list(string)
  default     = ["10.0.0.0/8"]
}

variable "certificate_arn" {
  description = "ARN del certificado de ACM para el listener HTTPS (gotcha: internal-alb-certificate)."
  type        = string
}

variable "container_image" {
  description = "Imagen de la API (en ECR, alcanzable por los VPC endpoints)."
  type        = string
}

variable "container_port" {
  description = "Puerto en el que escucha la API."
  type        = number
  default     = 8080
}

variable "health_check_path" {
  description = "Path del health check del target group."
  type        = string
  default     = "/healthz"
}

variable "desired_count" {
  description = "Tareas de ECS. Mínimo 1: el servicio no escala a cero."
  type        = number
  default     = 1
}

variable "db_instance_class" {
  description = "Clase de instancia de RDS."
  type        = string
  default     = "db.t4g.micro"
}

variable "db_multi_az" {
  description = "Multi-AZ para RDS (duplica el costo de cómputo)."
  type        = bool
  default     = false
}

variable "database_name" {
  description = "Base de datos de la aplicación."
  type        = string
  default     = "app"
}

variable "db_app_user" {
  description = "Usuario de base que usa la app con IAM auth (se crea con SQL, ver output app_user_sql)."
  type        = string
  default     = "app_iam"
}

variable "tags" {
  description = "Tags aplicados a todos los recursos."
  type        = map(string)
  default     = {}
}
outputs.tf
output "alb_dns_name" {
  description = "DNS interno del ALB (apuntá un registro de tu zona privada a este nombre)."
  value       = aws_lb.this.dns_name
}

output "db_address" {
  description = "Endpoint de RDS."
  value       = aws_db_instance.this.address
}

output "master_user_secret_arn" {
  description = "Secreto de Secrets Manager con la contraseña del master (solo para administración)."
  value       = aws_db_instance.this.master_user_secret[0].secret_arn
}

output "app_user_sql" {
  description = "SQL que hay que ejecutar como master para crear el usuario IAM de la app (gotcha: rds-iam-user-needs-sql)."
  value       = <<-SQL
    CREATE USER ${var.db_app_user};
    GRANT rds_iam TO ${var.db_app_user};
    GRANT CONNECT ON DATABASE ${var.database_name} TO ${var.db_app_user};
    -- Conectado a la base '${var.database_name}':
    GRANT USAGE ON SCHEMA public TO ${var.db_app_user};
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${var.db_app_user};
  SQL
}
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
  }
}