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.

Microsoft Azure

Web API privada Zero Trust con PostgreSQL sin contraseñas — AzureHTTPStokenPostgreSQL + TLSCliente en la red privadaIngress internoContainer Appsenvironment interno1 gotcha en este rolAPI en contenedorAzure Container Apps1 gotcha en este rolIdentidad sin secretosUser-assigned ManagedIdentity2 gotchas en este rolAcceso privado a la basePrivate Endpoint(subresourcepostgresqlServer)1 gotcha en este rolPostgreSQL gestionadoAzure Database forPostgreSQL – FlexibleServer4 gotchas en este rol

Implementación

Ingress interno
Container Apps environment internointernal_load_balancer_enabled + zona DNS privada del default domainVenís de AWS: allá este rol es Application Load Balancer interno.Venís de GCP: allá este rol es Cloud Run ingress "internal".
API en contenedor
Azure Container AppsWorkload profiles (Consumption), ingress limitado a la VNetVenís de AWS: allá este rol es Amazon ECS on Fargate.Venís de GCP: allá este rol es Cloud Run (service).
Identidad sin secretos
User-assigned Managed IdentityRol en PostgreSQL creado con pgaadauth_create_principalVenís de AWS: allá este rol es IAM Role (ECS task role).Venís de GCP: allá este rol es Service Account del servicio.
Acceso privado a la base
Private Endpoint (subresource postgresqlServer)+ zona privatelink.postgres.database.azure.comVenís de AWS: allá este rol es RDS en subnets privadas + security groups.Venís de GCP: allá este rol es Private services access (VPC peering).
PostgreSQL gestionado
Azure Database for PostgreSQL – Flexible ServerSolo Entra ID (password_auth_enabled = false)Venís de AWS: allá este rol es Amazon RDS for PostgreSQL.Venís de GCP: allá este rol es Cloud SQL for PostgreSQL.

Modelo de costo

Escala a cero parcial

Costo fijo aunque no haya tráfico:

  • PostgreSQL Flexible Server (cómputo + storage)
  • Private Endpoint de la base (cargo por hora + GB)
  • Zonas DNS privadas

Container Apps (perfil Consumption) escala a cero y no tiene costo de ingress aparte: el ILB del environment está incluido. El piso lo pone la base de datos.

Piso mensual sin tráfico

USD 24,39/ 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.

⚖ Comparar SKUs: PostgreSQL administrado →

Letra chica · 6 gotchas

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

Por ARM se agregan administradores Entra; el rol de la app se crea con SQL

Desde el portal de Azure o la ARM API se agregan principals Entra como administradores. Con isAdmin=true, pgaadauth_create_principal les da azure_pg_admin, CREATEROLE y CREATEDB. Para que la identidad de la app sea un usuario común, un admin Entra puede ejecutar pgaadauth_create_principal('<nombre>', false, false). Para identidades administradas y service principals el nombre es el display name. El módulo deja el SQL exacto como output.

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

El token de Entra para PostgreSQL no se pide para el FQDN del servidor

La contraseña es un access token cuyo resource es https://ossrdbms-aad.database.windows.net (en nube pública), no la URL del servidor. Es el error más común al migrar desde RDS, donde el "token" es una firma generada localmente con las credenciales IAM. Las librerías de Azure Identity lo resuelven si pedís el scope correcto.

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

La subnet de ACA no se puede agrandar y su tamaño mínimo depende del tipo de environment

Un environment con workload profiles (el default) necesita una subnet dedicada de al menos /27 delegada a Microsoft.App/environments; uno Consumption-only (legacy) necesita /23 y NO puede estar delegada. El tamaño no se puede cambiar después de crear el environment, y en despliegues single-revision el consumo de IPs se duplica temporalmente. Dimensioná pensando en el pico de réplicas, no en el día uno.

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

Un environment interno no resuelve su propio dominio desde la VNet

Con el ILB, las apps quedan en <app>.<default_domain> pero nadie resuelve ese nombre hasta que creás una zona DNS privada con el nombre del default domain, registros A "*" y "@" al static IP del environment, y la vinculás a las VNets cliente. Sin eso, la API "no existe" aunque esté corriendo.

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

En PostgreSQL Flexible, VNet integration vs. Private Endpoint se elige una sola vez

El modo de conectividad se define al crear el servidor y no se puede cambiar. Un servidor con VNet integration no admite Private Endpoints y no se puede mover a otra subnet o VNet. Este módulo usa modo "acceso público" con public_network_access_enabled = false + Private Endpoint, que es lo que permite consumirlo desde hub-and-spoke, peerings y on-premises con el mismo modelo que el resto de los PaaS.

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

Detener la base corta el cómputo, pero arranca sola a los 7 días

PostgreSQL Flexible factura cómputo mientras está encendido. Detenerlo corta el cobro de cómputo (no el de storage), pero el servicio lo vuelve a arrancar automáticamente a los 7 días. Para entornos de desarrollo, automatizá el stop en vez de confiar en un stop manual.

⚑ Reportar desactualizado

Terraform

azurerm ~> 5.8✓ fmt · validate · tflint · checkov · terraform testGitHub ↗
main.tf
# Web API privada Zero Trust — Azure
#
# Container Apps en un environment interno (ILB) con identidad administrada,
# conectándose por Private Endpoint a PostgreSQL Flexible Server que solo
# acepta autenticación Entra ID (sin contraseñas).

data "azurerm_client_config" "current" {}

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
}

# Dedicada al environment y delegada (gotcha: aca-subnet-sizing).
resource "azurerm_subnet" "aca" {
  name                 = "snet-aca"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [var.aca_subnet_prefix]

  delegation {
    name = "aca"
    service_delegation {
      name    = "Microsoft.App/environments"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }
}

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

resource "azurerm_network_security_group" "this" {
  for_each            = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
  name                = "nsg-${var.name}-${each.key}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  tags                = var.tags
}

resource "azurerm_subnet_network_security_group_association" "this" {
  for_each                  = { aca = azurerm_subnet.aca.id, endpoints = azurerm_subnet.endpoints.id }
  subnet_id                 = each.value
  network_security_group_id = azurerm_network_security_group.this[each.key].id
}

# --- Container Apps (environment interno) -------------------------------------

resource "azurerm_container_app_environment" "this" {
  name                           = "cae-${var.name}"
  resource_group_name            = azurerm_resource_group.this.name
  location                       = azurerm_resource_group.this.location
  infrastructure_subnet_id       = azurerm_subnet.aca.id
  internal_load_balancer_enabled = true

  workload_profile {
    name                  = "Consumption"
    workload_profile_type = "Consumption"
  }

  tags = var.tags

  depends_on = [azurerm_subnet_network_security_group_association.this]
}

# Sin esta zona nadie resuelve <app>.<default_domain> (gotcha: aca-internal-needs-private-dns).
resource "azurerm_private_dns_zone" "aca" {
  name                = azurerm_container_app_environment.this.default_domain
  resource_group_name = azurerm_resource_group.this.name
  tags                = var.tags
}

resource "azurerm_private_dns_a_record" "aca" {
  for_each            = toset(["*", "@"])
  name                = each.key
  private_dns_zone_id = azurerm_private_dns_zone.aca.id
  ttl                 = 300
  records             = [azurerm_container_app_environment.this.static_ip_address]
}

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

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

resource "azurerm_container_app" "api" {
  name                         = "ca-${var.name}-api"
  resource_group_name          = azurerm_resource_group.this.name
  container_app_environment_id = azurerm_container_app_environment.this.id
  revision_mode                = "Single"
  workload_profile_name        = "Consumption"

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.api.id]
  }

  ingress {
    # En un environment interno, external_enabled = true significa "toda la VNet";
    # false limita el acceso a otras apps del mismo environment.
    external_enabled           = true
    target_port                = var.container_port
    allow_insecure_connections = false

    traffic_weight {
      latest_revision = true
      percentage      = 100
    }
  }

  template {
    min_replicas = 0
    max_replicas = var.max_replicas

    container {
      name   = "api"
      image  = var.container_image
      cpu    = 0.25
      memory = "0.5Gi"

      # Sin contraseña: la app pide un token para ossrdbms-aad con esta identidad
      # (gotcha: pg-entra-token-resource).
      env {
        name  = "AZURE_CLIENT_ID"
        value = azurerm_user_assigned_identity.api.client_id
      }
      env {
        name  = "PGHOST"
        value = azurerm_postgresql_flexible_server.this.fqdn
      }
      env {
        name  = "PGUSER"
        value = azurerm_user_assigned_identity.api.name
      }
      env {
        name  = "PGDATABASE"
        value = var.database_name
      }
      env {
        name  = "PGSSLMODE"
        value = "require"
      }
    }
  }

  tags = var.tags
}

# --- PostgreSQL (solo Entra ID, sin acceso público) ---------------------------

resource "azurerm_postgresql_flexible_server" "this" {
  #checkov:skip=CKV_AZURE_136:Backup geo-redundante opcional; duplica costo de backup. Activar en producción crítica.
  name                          = var.postgres_server_name
  resource_group_name           = azurerm_resource_group.this.name
  location                      = azurerm_resource_group.this.location
  version                       = "17"
  sku_name                      = var.postgres_sku
  storage_mb                    = 32768
  backup_retention_days         = 7
  geo_redundant_backup_enabled  = false
  public_network_access_enabled = false

  # Modo "acceso público" + Private Endpoint: no se puede cambiar después
  # (gotcha: pg-network-mode-is-permanent).
  authentication {
    active_directory_auth_enabled = true
    password_auth_enabled         = false
    tenant_id                     = data.azurerm_client_config.current.tenant_id
  }

  tags = var.tags

  lifecycle {
    ignore_changes = [zone]
  }
}

resource "azurerm_postgresql_flexible_server_active_directory_administrator" "this" {
  server_name         = azurerm_postgresql_flexible_server.this.name
  resource_group_name = azurerm_resource_group.this.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  object_id           = var.entra_admin.object_id
  principal_name      = var.entra_admin.principal_name
  principal_type      = var.entra_admin.principal_type
}

resource "azurerm_postgresql_flexible_server_database" "app" {
  name      = var.database_name
  server_id = azurerm_postgresql_flexible_server.this.id
  collation = "en_US.utf8"
  charset   = "UTF8"
}

resource "azurerm_private_dns_zone" "postgres" {
  name                = "privatelink.postgres.database.azure.com"
  resource_group_name = azurerm_resource_group.this.name
  tags                = var.tags
}

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

resource "azurerm_private_endpoint" "postgres" {
  name                = "pe-${var.name}-postgres"
  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}-postgres"
    private_connection_resource_id = azurerm_postgresql_flexible_server.this.id
    subresource_names              = ["postgresqlServer"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [azurerm_private_dns_zone.postgres.id]
  }

  tags = var.tags
}
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 "location" {
  description = "Región de Azure."
  type        = string
  default     = "eastus2"
}

variable "postgres_server_name" {
  description = "Nombre global único del servidor PostgreSQL (3-63, minúsculas, números y guiones)."
  type        = string

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

variable "postgres_sku" {
  description = "SKU de PostgreSQL Flexible Server."
  type        = string
  default     = "B_Standard_B1ms"
}

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

variable "entra_admin" {
  description = "Administrador Entra de PostgreSQL (recomendado: un grupo). Es quien ejecuta el SQL que crea el rol de la app."
  type = object({
    object_id      = string
    principal_name = string
    principal_type = string
  })

  validation {
    condition     = contains(["Group", "User", "ServicePrincipal"], var.entra_admin.principal_type)
    error_message = "principal_type: Group, User o ServicePrincipal."
  }
}

variable "container_image" {
  description = "Imagen de la API."
  type        = string
  default     = "mcr.microsoft.com/k8se/quickstart:latest"
}

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

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

variable "aca_subnet_prefix" {
  description = "Subnet dedicada al environment (mínimo /27 con workload profiles; no se puede agrandar después)."
  type        = string
  default     = "10.40.0.0/24"
}

variable "endpoints_subnet_prefix" {
  description = "Subnet para Private Endpoints."
  type        = string
  default     = "10.40.1.0/24"
}

variable "max_replicas" {
  description = "Máximo de réplicas de la API."
  type        = number
  default     = 10
}

variable "tags" {
  description = "Tags aplicados a todos los recursos."
  type        = map(string)
  default     = {}
}
outputs.tf
output "api_fqdn" {
  description = "FQDN interno de la API (resuelve vía la zona DNS privada del environment)."
  value       = azurerm_container_app.api.ingress[0].fqdn
}

output "postgres_fqdn" {
  description = "FQDN del servidor PostgreSQL (resuelve a la IP del Private Endpoint desde la VNet)."
  value       = azurerm_postgresql_flexible_server.this.fqdn
}

output "app_role_sql" {
  description = "SQL que un admin Entra tiene que ejecutar para crear el rol de la app con permisos mínimos (gotcha: pg-entra-app-role-needs-sql)."
  value       = <<-SQL
    -- Conectado a la base 'postgres' como administrador Entra:
    SELECT * FROM pgaadauth_create_principal('${azurerm_user_assigned_identity.api.name}', false, false);
    -- Conectado a la base '${var.database_name}':
    GRANT CONNECT ON DATABASE "${var.database_name}" TO "${azurerm_user_assigned_identity.api.name}";
    GRANT USAGE ON SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${azurerm_user_assigned_identity.api.name}";
  SQL
}
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

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
}