🔐 Key Vault: secretos que no pasan por el estado¶
1. Qué guarda Key Vault y qué no¶
Key Vault gestiona tres tipos de objeto con tres APIs y tres juegos de roles distintos. Confundirlos lleva a guardar un certificado como secreto (funciona, pero pierdes la renovación) o a dar Key Vault Administrator a quien solo necesita leer una contraseña.
| Objeto | Qué es | En Moodle | Roles RBAC | Topaz |
|---|---|---|---|---|
| Secreto | Texto opaco ≤ 25 KB, versionado, con expiración | Contraseña de MySQL, clave de la cuenta de storage, salt de Moodle, connection string de App Insights | Secrets Officer (escribe), Secrets User (lee) | ✅ |
| Clave | Par RSA/EC que nunca sale del vault: cifra, firma, envuelve otras claves | Clave gestionada por el cliente (CMK) para cifrar moodledata y los discos |
Crypto Officer, Crypto Service Encryption User (para el servicio que cifra) | plan ✅ / apply según versión |
| Certificado | X.509 + clave privada + política de renovación; expone también un secreto (PFX) y una clave | TLS del Application Gateway; en pro, emitido por CA integrada | Certificates Officer; el gateway lee con Secrets User | plan ✅ / apply ❌ |
🔷 Lo que no va a Key Vault. Configuración que no es secreta (nombre del servidor, puerto, URL de Moodle): eso son variables o outputs, y meterlo en el vault solo añade latencia y llamadas facturables. Ficheros grandes (un
.pfxde 25 KB es el límite). Y estados de Terraform: van al backend con cifrado y RBAC propio (página 4). Regla práctica: Key Vault guarda lo que, si se filtra, hay que rotar.
2. El vault: RBAC, borrado suave y red¶
El original usa access_policy embebido y a la vez recomienda azurerm_key_vault_access_policy: si haces ambas cosas, cada plan quiere borrar lo que la otra creó. Las políticas de acceso son el modelo antiguo: un vault-por-vault, sin herencia, sin ámbito por secreto y sin Privileged Identity Management. Con rbac_authorization_enabled = true el acceso se gestiona con los mismos azurerm_role_assignment del resto de Azure, y puede afinarse hasta un secreto concreto.
# providers.tf: cómo se comporta el provider con el borrado suave
provider "azurerm" {
features {
key_vault {
purge_soft_delete_on_destroy = var.entorno != "pro" # dev: destroy limpia de verdad; pro: el vault queda recuperable 90 días
recover_soft_deleted_key_vaults = true # si existe uno borrado con el mismo nombre, lo recupera en vez de fallar
}
}
}
data "azurerm_client_config" "actual" {}
resource "azurerm_key_vault" "moodle" {
name = "kv-moodle-${var.entorno}-${substr(md5(azurerm_resource_group.moodle.id), 0, 6)}" # 3-24 chars, único global, empieza por letra
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
tenant_id = data.azurerm_client_config.actual.tenant_id
sku_name = "standard" # premium solo si necesitas claves en HSM
rbac_authorization_enabled = true # sin access_policy: todo por azurerm_role_assignment
soft_delete_retention_days = var.entorno == "pro" ? 90 : 7 # siempre activo; el mínimo es 7
purge_protection_enabled = var.entorno == "pro" # pro: nadie puede purgar antes del plazo, ni tú. Irreversible una vez activado.
public_network_access_enabled = var.entorno != "pro" # pro: solo private endpoint ([página 13](index.md#pagina-13))
network_acls {
default_action = var.entorno == "pro" ? "Deny" : "Allow"
bypass = "AzureServices" # el Application Gateway y el cifrado de discos entran por aquí
ip_rules = var.entorno == "pro" ? [] : var.ips_admin
}
enabled_for_deployment = false # solo si una VM va a leer certificados por la vía clásica
enabled_for_disk_encryption = var.entorno == "pro" # cifrado de discos con CMK
enabled_for_template_deployment = false
tags = local.tags
}
# Quien ejecuta Terraform escribe secretos. Nada más: no necesita Administrator.
resource "azurerm_role_assignment" "tf_escribe_secretos" {
scope = azurerm_key_vault.moodle.id
role_definition_name = "Key Vault Secrets Officer"
principal_id = data.azurerm_client_config.actual.object_id
}
# RBAC tarda hasta unos minutos en propagarse. Sin esta pausa, el primer apply falla con ForbiddenByRbac al crear el secreto.
resource "time_sleep" "rbac" {
depends_on = [azurerm_role_assignment.tf_escribe_secretos]
create_duration = "60s"
}
output "vault_uri" { value = azurerm_key_vault.moodle.vault_uri }
| Rol | Quién lo necesita en Moodle | Ámbito recomendado |
|---|---|---|
| Key Vault Secrets Officer | La identidad que ejecuta apply (crea y rota secretos) |
El vault |
| Key Vault Secrets User | La identidad gestionada del VMSS (lee la contraseña de MySQL); el Application Gateway (lee el PFX) | El secreto concreto (resource_versionless_id), no el vault |
| Key Vault Certificates Officer | La identidad de apply si Terraform gestiona el certificado |
El vault |
| Key Vault Crypto Service Encryption User | La identidad de la cuenta de storage / Disk Encryption Set que cifra con CMK | La clave |
| Key Vault Administrator | Nadie de forma permanente. Con PIM, activable para incidentes | — |
3. Escribir un secreto sin que Terraform lo guarde¶
value = "SuperSecretPassword123!" tiene cuatro copias antes de llegar al vault: el fichero .tf, el historial de Git, la salida del plan y el estado, donde sensitive solo oculta la pantalla, no el JSON. La combinación correcta desde Terraform 1.11 es un recurso efímero que genera la contraseña (existe solo durante la ejecución) y un argumento write-only (value_wo) que la envía a Azure sin persistirla. Como Terraform no puede comparar lo que no guarda, un segundo campo, value_wo_version, le dice cuándo debe volver a escribir: es el asa de la rotación (11.6).
# Se genera en cada ejecución y se descarta al terminar: nunca entra en el plan ni en el estado
ephemeral "random_password" "mysql" {
length = 32
special = true
override_special = "!#%^*-_=+" # sin comillas, $, ni & para no pelearse con config.php ni con la shell de cloud-init
}
variable "mysql_password_version" { # súbelo para rotar (11.6 lo automatiza)
type = number
default = 1
}
resource "azurerm_key_vault_secret" "mysql" {
name = "mysql-moodle-password"
key_vault_id = azurerm_key_vault.moodle.id
value_wo = ephemeral.random_password.mysql.result # write-only: se envía, no se guarda
value_wo_version = var.mysql_password_version # Terraform solo reescribe cuando esto cambia
content_type = "password" # metadato útil para inventario y para Defender
expiration_date = timeadd(plantimestamp(), "2160h") # 90 días: Key Vault avisa (Event Grid) y Defender lo señala si caduca
tags = merge(local.tags, { consumidor = "vmss-moodle-web", rota = "terraform" })
lifecycle { ignore_changes = [expiration_date] } # si no, cada plan la mueve; la rotación la fija de nuevo
depends_on = [time_sleep.rbac]
}
# Secretos que no genera Terraform sino otro recurso: la clave de storage sí pasa por el estado (es un atributo del recurso),
# pero al menos la VM no la recibe por cloud-init sino que la lee del vault con su identidad.
resource "azurerm_key_vault_secret" "storage_key" {
name = "moodledata-storage-key"
key_vault_id = azurerm_key_vault.moodle.id
value_wo = azurerm_storage_account.moodledata.primary_access_key
value_wo_version = 1
content_type = "storage-access-key"
depends_on = [time_sleep.rbac]
}
# Mejor todavía: que la VM no use clave de storage. Identidad gestionada + rol "Storage Blob Data Contributor" (página 13).
⚠️ Compruébalo, no te lo creas. Después del
apply:terraform state pull | jq '.resources[] | select(.type == "azurerm_key_vault_secret") | .instances[].attributes | {name, value, value_wo}'. Convalueverás la contraseña en claro (ysensitiveno cambia eso). Convalue_woverásnull. El laboratorio de 11.8 hace exactamente esta comparación.
4. Leer un secreto: data lo guarda, ephemeral no¶
La nota del original ("usa data para leer secretos sin exponerlos en el estado") es el error más peligroso de la página: un data "azurerm_key_vault_secret" se guarda completo en el estado, valor incluido, en cada refresh. Y aunque el origen fuera efímero, el destino importa: si asignas el valor a administrator_password, ese atributo se guarda. Hace falta que ambos extremos sean transitorios: ephemeral para leer y el argumento _wo del consumidor para escribir.
# ❌ El original: el valor acaba en el estado dos veces (en el data y en el atributo del servidor)
# data "azurerm_key_vault_secret" "db_password" { name = "db-password" key_vault_id = azurerm_key_vault.example.id }
# administrator_login_password = data.azurerm_key_vault_secret.db_password.value
# ✅ Lectura efímera: existe solo durante plan/apply, no se escribe en el estado
ephemeral "azurerm_key_vault_secret" "mysql" {
name = azurerm_key_vault_secret.mysql.name # la dependencia garantiza que se lee después de escribirse
key_vault_id = azurerm_key_vault.moodle.id
}
resource "azurerm_mysql_flexible_server" "moodle" {
name = "mysql-moodle-${var.entorno}-${substr(md5(azurerm_resource_group.moodle.id), 0, 6)}"
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
version = "8.0.21"
sku_name = local.t.mysql # tallas por entorno ([página 15](index.md#pagina-15))
administrator_login = "moodleadmin"
administrator_password_wo = ephemeral.azurerm_key_vault_secret.mysql.value # write-only: se envía, no se guarda
administrator_password_wo_version = var.mysql_password_version # la misma versión que el secreto: cambian juntos
delegated_subnet_id = module.red.subnet_ids["datos"]
private_dns_zone_id = azurerm_private_dns_zone.mysql.id
backup_retention_days = var.entorno == "pro" ? 35 : 7
tags = local.tags
}
# Un valor efímero solo puede ir a sitios efímeros: argumentos _wo, configuración de providers, provisioners/connection,
# otros recursos efímeros y locals (que se vuelven efímeros). En un output o en un atributo normal, Terraform lo rechaza:
# "Invalid use of ephemeral value" — y eso es exactamente lo que quieres.
🔷 Y si el recurso destino no tiene argumento
_wo. Aún hay recursos sin variante write-only. Entonces el valor se guarda en el estado, hagas lo que hagas en el origen. Las defensas pasan a ser el backend (cifrado, RBAC mínimo, sin copias locales, página 4) y la rotación frecuente: un secreto que cambia cada 90 días en el estado de hace 6 meses ya no sirve a nadie. Y comprueba el changelog del provider antes de asumir que no existe: cada versión añade argumentos_wo.
5. La aplicación lee sola: identidad gestionada¶
El script Python del original hace print del secreto, y la tentación equivalente en Terraform es pasarlo a la VM por custom_data: cloud-init se guarda en claro en el estado y en el disco. La VM no debe recibir la contraseña; debe pedirla con su propia identidad. Terraform solo le pasa dos datos públicos: la URI del vault y el client_id de la identidad. El permiso se da sobre el secreto, no sobre el vault: si la VM se compromete, el atacante lee una contraseña, no todas.
resource "azurerm_user_assigned_identity" "web" { # user-assigned: existe antes que el VMSS, así el rol está listo al primer arranque
name = "id-moodle-web-${var.entorno}"
resource_group_name = azurerm_resource_group.moodle.name
location = azurerm_resource_group.moodle.location
}
resource "azurerm_role_assignment" "web_lee_mysql" {
scope = azurerm_key_vault_secret.mysql.resource_versionless_id # este secreto, todas sus versiones. No el vault.
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.web.principal_id
}
resource "azurerm_linux_virtual_machine_scale_set" "web" {
# … (páginas 14 y 15)
identity { type = "UserAssigned", identity_ids = [azurerm_user_assigned_identity.web.id] }
custom_data = base64(templatefile("${path.module}/cloud-init.yaml", {
vault_uri = azurerm_key_vault.moodle.vault_uri # público
client_id = azurerm_user_assigned_identity.web.client_id # público
secreto = azurerm_key_vault_secret.mysql.name # público. La contraseña, jamás.
}))
depends_on = [azurerm_role_assignment.web_lee_mysql]
}
#cloud-config (cloud-init.yaml)
package_update: true
packages: [jq, curl]
write_files:
- path: /usr/local/sbin/moodle-secrets.sh
permissions: "0750"
content: |
#!/usr/bin/env bash
set -euo pipefail
# 1. Token de la identidad gestionada por IMDS. client_id es obligatorio si la VM tiene más de una identidad.
TOKEN=$(curl -sf -H 'Metadata: true' \
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net&client_id=${client_id}" | jq -r .access_token)
# 2. El secreto, por su nombre: siempre la versión actual. Tras una rotación basta re-ejecutar este script.
PASS=$(curl -sf -H "Authorization: Bearer $TOKEN" "${vault_uri}secrets/${secreto}?api-version=7.4" | jq -r .value)
# 3. A un fichero 0600 que solo lee el usuario de Apache. Nunca a stdout, nunca a un log.
install -o www-data -g www-data -m 0600 /dev/null /etc/moodle/db.env
printf 'MOODLE_DB_PASS=%s\n' "$PASS" > /etc/moodle/db.env
unset PASS TOKEN
- path: /etc/systemd/system/moodle-secrets.timer # relee cada 6 h: la rotación llega sin reimage
content: |
[Timer]
OnBootSec=1min
OnUnitActiveSec=6h
[Install]
WantedBy=timers.target
- path: /etc/systemd/system/moodle-secrets.service
content: |
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/moodle-secrets.sh
ExecStartPost=/bin/systemctl reload apache2
runcmd:
- mkdir -p /etc/moodle
- systemctl enable --now moodle-secrets.timer
# config.php lee getenv('MOODLE_DB_PASS') vía EnvironmentFile en la unidad de Apache/PHP-FPM.
# Lo mismo en otros consumidores, sin código:
# App Service / Functions: app_settings = { DB_PASS = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.mysql.versionless_id})" }
# AKS: Secrets Store CSI Driver + Workload Identity: el secreto aparece como fichero en el pod
# Python (el original): DefaultAzureCredential está bien; el error era el print. En la VM usa ManagedIdentityCredential(client_id=…)
6. Rotación: un solo apply, dos extremos¶
El original propone una Function que "actualiza Key Vault y la base de datos": dos escrituras separadas, y si la segunda falla Moodle se queda sin acceso. Con value_wo_version en el secreto y administrator_password_wo_version en MySQL atados a la misma variable, ambos se reescriben en el mismo apply con el mismo valor efímero, y el timer de la VM recoge el cambio. La versión la puede subir una persona o el calendario.
# Rotación programada: time_rotating se recrea al cumplirse el plazo y su fecha base cambia → la versión cambia → ambos extremos se reescriben
resource "time_rotating" "mysql" {
rotation_days = 90
}
locals {
mysql_password_version = tonumber(formatdate("YYYYMMDD", time_rotating.mysql.rfc3339)) # p. ej. 20260910; sube solo en cada rotación
}
# En 11.3 y 11.4: value_wo_version = local.mysql_password_version / administrator_password_wo_version = local.mysql_password_version
# El pipeline (página 12) hace un apply semanal programado: cuando toque, rota; si no, "No changes".
# Rotación de urgencia (filtración): terraform apply -replace=time_rotating.mysql
# La expiración del secreto se realinea en cada rotación
# expiration_date = timeadd(time_rotating.mysql.rfc3339, "2400h") # 100 días: margen sobre los 90 de rotación
# Claves: rotación nativa, sin Terraform en el bucle
resource "azurerm_role_assignment" "tf_crypto" {
scope = azurerm_key_vault.moodle.id
role_definition_name = "Key Vault Crypto Officer"
principal_id = data.azurerm_client_config.actual.object_id
}
resource "azurerm_key_vault_key" "cmk" {
name = "cmk-moodledata"
key_vault_id = azurerm_key_vault.moodle.id
key_type = "RSA"
key_size = 3072
key_opts = ["wrapKey", "unwrapKey"] # una CMK envuelve claves de datos; no firma ni cifra directamente (el original daba los seis)
rotation_policy {
automatic { time_before_expiry = "P30D" } # Key Vault crea la versión nueva solo; Storage la adopta si usas versionless_id
expire_after = "P365D"
notify_before_expiry = "P29D"
}
depends_on = [azurerm_role_assignment.tf_crypto, time_sleep.rbac]
}
7. Certificados: emitir en el vault, no importar un PFX¶
El original importa certificate.pfx con la contraseña en claro en el código: el fichero y su clave privada acaban en Git, y alguien tendrá que repetir la operación a mano cuando caduque. Key Vault puede emitir el certificado (autofirmado en dev, con CA integrada en pro), guardar la clave privada sin que salga y renovarlo solo. El Application Gateway lo lee por referencia con su identidad.
resource "azurerm_role_assignment" "tf_certs" {
scope = azurerm_key_vault.moodle.id
role_definition_name = "Key Vault Certificates Officer"
principal_id = data.azurerm_client_config.actual.object_id
}
resource "azurerm_key_vault_certificate" "tls" {
name = "tls-moodle"
key_vault_id = azurerm_key_vault.moodle.id
certificate_policy {
issuer_parameters { name = var.entorno == "pro" ? "DigiCert" : "Self" } # pro: azurerm_key_vault_certificate_issuer con la cuenta de la CA
key_properties { exportable = true, key_type = "RSA", key_size = 2048, reuse_key = false }
secret_properties { content_type = "application/x-pkcs12" }
lifetime_action {
trigger { days_before_expiry = 30 }
action { action_type = "AutoRenew" } # con "Self" o CA integrada renueva solo; con CA manual, "EmailContacts"
}
x509_certificate_properties {
subject = "CN=${var.dominio_moodle}"
validity_in_months = 12
key_usage = ["digitalSignature", "keyEncipherment"]
extended_key_usage = ["1.3.6.1.5.5.7.3.1"] # serverAuth
subject_alternative_names { dns_names = [var.dominio_moodle] }
}
}
depends_on = [azurerm_role_assignment.tf_certs, time_sleep.rbac]
}
# El gateway lee el PFX como secreto, con identidad propia y permiso sobre ese secreto
resource "azurerm_user_assigned_identity" "agw" { name = "id-moodle-agw", resource_group_name = azurerm_resource_group.moodle.name, location = azurerm_resource_group.moodle.location }
resource "azurerm_role_assignment" "agw_lee_cert" {
scope = azurerm_key_vault.moodle.id # el secreto del certificado no tiene resource_id propio en el provider: ámbito vault, solo Secrets User
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.agw.principal_id
}
resource "azurerm_application_gateway" "moodle" {
# …
identity { type = "UserAssigned", identity_ids = [azurerm_user_assigned_identity.agw.id] }
ssl_certificate {
name = "tls-moodle"
key_vault_secret_id = azurerm_key_vault_certificate.tls.versionless_secret_id # sin versión: la renovación llega sola en < 4 h
}
}
# Importar uno existente (migración): certificate { contents = filebase64(var.pfx_path) password = var.pfx_password }
# La contraseña del PFX se guarda en el estado: úsalo una vez, rota el PFX y pasa a emisión gestionada.
8. Laboratorio en Topaz¶
El laboratorio demuestra lo esencial de la página con evidencia, no con fe: el mismo secreto escrito con value y con value_wo, y lo que aparece en el estado en cada caso; la lectura efímera; el versionado al rotar. Lo que Topaz no evalúa (RBAC, IMDS, MySQL, certificados) se valida con plan.
mkdir -p ~/tf-kv && cd ~/tf-kv && cp ~/tf-st/providers.tf .
# ─── 1. Vault con RBAC y dos secretos: el del original y el correcto ────────────
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.11"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.30" }
random = { source = "hashicorp/random", version = "~> 3.7" }
time = { source = "hashicorp/time", version = "~> 0.13" }
}
}
variable "entorno" { type = string, default = "dev" }
variable "location" { type = string, default = "eastus" }
variable "version_secreto" { type = number, default = 1 }
variable "validar_azure" { type = bool, default = false }
locals { tags = { proyecto = "moodle", entorno = var.entorno, gestion = "terraform" } }
data "azurerm_client_config" "actual" {}
resource "azurerm_resource_group" "kv" { name = "rg-kv-lab-${var.entorno}", location = var.location, tags = local.tags }
resource "azurerm_key_vault" "moodle" {
name = "kv-lab-${var.entorno}-${substr(md5(azurerm_resource_group.kv.id), 0, 6)}"
resource_group_name = azurerm_resource_group.kv.name
location = azurerm_resource_group.kv.location
tenant_id = data.azurerm_client_config.actual.tenant_id
sku_name = "standard"
rbac_authorization_enabled = true
soft_delete_retention_days = 7
purge_protection_enabled = false # dev: que destroy limpie
tags = local.tags
}
resource "azurerm_role_assignment" "tf" {
scope = azurerm_key_vault.moodle.id
role_definition_name = "Key Vault Secrets Officer"
principal_id = data.azurerm_client_config.actual.object_id
}
resource "time_sleep" "rbac" { depends_on = [azurerm_role_assignment.tf], create_duration = "10s" } # en Azure real, 60s
# (a) Como el original: el valor en el código
resource "azurerm_key_vault_secret" "mal" {
name = "demo-en-claro"
key_vault_id = azurerm_key_vault.moodle.id
value = "SuperSecretPassword123!"
depends_on = [time_sleep.rbac]
}
# (b) Efímero + write-only
ephemeral "random_password" "mysql" { length = 32, special = true, override_special = "!#%^*-_=+" }
resource "azurerm_key_vault_secret" "bien" {
name = "mysql-moodle-password"
key_vault_id = azurerm_key_vault.moodle.id
value_wo = ephemeral.random_password.mysql.result
value_wo_version = var.version_secreto
content_type = "password"
depends_on = [time_sleep.rbac]
}
# (c) Lectura efímera, consumida en un contexto efímero (provisioner) para poder observarla sin guardarla
ephemeral "azurerm_key_vault_secret" "leido" {
name = azurerm_key_vault_secret.bien.name
key_vault_id = azurerm_key_vault.moodle.id
}
resource "terraform_data" "prueba_lectura" {
triggers_replace = [var.version_secreto]
provisioner "local-exec" {
command = "echo 'leído desde el vault: ${length(ephemeral.azurerm_key_vault_secret.leido.value)} caracteres, empieza por ${substr(ephemeral.azurerm_key_vault_secret.leido.value, 0, 1)}…'"
}
}
output "vault" { value = azurerm_key_vault.moodle.name }
EOF
terraform init && terraform apply -auto-approve
# "leído desde el vault: 32 caracteres, empieza por …" — el valor llegó a Azure y volvió, sin pasar por el estado
# ─── 2. La prueba: qué hay en el estado ──────────────────────────────────────────
terraform state pull | jq '.resources[] | select(.type == "azurerm_key_vault_secret") | .instances[].attributes | {name, value, value_wo, value_wo_version}'
# demo-en-claro: "value": "SuperSecretPassword123!" ← en claro; sensitive no cambia esto
# mysql-moodle-password: "value": null, "value_wo": null ← no está
terraform state pull | jq '.resources[] | select(.type == "terraform_data")' # nada del secreto tampoco
grep -c SuperSecret terraform.tfstate; grep -c "$(az keyvault secret show --vault-name $(terraform output -raw vault) -n mysql-moodle-password --query value -o tsv | cut -c1-8)" terraform.tfstate # 1 y 0
terraform plan -no-color | grep -c "value_wo" || true # el plan tampoco muestra el valor: "(write-only attribute)"
# ─── 3. Rotar: sube la versión ──────────────────────────────────────────────────
terraform apply -auto-approve -var version_secreto=2
az keyvault secret list-versions --vault-name $(terraform output -raw vault) -n mysql-moodle-password --query "[].{v:id, creado:attributes.created}" -o table # dos versiones
terraform apply -auto-approve -var version_secreto=2 | tail -1 # "No changes": Terraform no compara valores, solo la versión
# ─── 4. Lo que Topaz no evalúa: validate + plan ─────────────────────────────────
cat > azure.tf <<'EOF'
resource "azurerm_user_assigned_identity" "web" {
count = var.validar_azure ? 1 : 0
name = "id-kv-lab-web", resource_group_name = azurerm_resource_group.kv.name, location = azurerm_resource_group.kv.location
}
resource "azurerm_role_assignment" "web_lee" {
count = var.validar_azure ? 1 : 0
scope = azurerm_key_vault_secret.bien.resource_versionless_id # ámbito: el secreto
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.web[0].principal_id
}
resource "azurerm_key_vault_key" "cmk" {
count = var.validar_azure ? 1 : 0
name = "cmk-lab", key_vault_id = azurerm_key_vault.moodle.id, key_type = "RSA", key_size = 3072, key_opts = ["wrapKey", "unwrapKey"]
rotation_policy { automatic { time_before_expiry = "P30D" }, expire_after = "P365D", notify_before_expiry = "P29D" }
}
resource "azurerm_key_vault_certificate" "tls" {
count = var.validar_azure ? 1 : 0
name = "tls-lab", key_vault_id = azurerm_key_vault.moodle.id
certificate_policy {
issuer_parameters { name = "Self" }
key_properties { exportable = true, key_type = "RSA", key_size = 2048, reuse_key = false }
secret_properties { content_type = "application/x-pkcs12" }
lifetime_action { trigger { days_before_expiry = 30 }, action { action_type = "AutoRenew" } }
x509_certificate_properties { subject = "CN=moodle.ejemplo.edu", validity_in_months = 12, key_usage = ["digitalSignature", "keyEncipherment"], subject_alternative_names { dns_names = ["moodle.ejemplo.edu"] } }
}
}
EOF
terraform validate && terraform plan -var validar_azure=true -no-color | grep -E "Plan:|scope"
# El scope del role assignment termina en /secrets/mysql-moodle-password: permiso por secreto, no por vault.
# El original, tal cual: access_policy embebido + recurso access_policy aparte → validate pasa, plan oscila
# (cada apply borra la política del otro). Y data.azurerm_key_vault_secret → mira su entrada en el estado: "value" en claro.
# ─── 5. Limpiar ────────────────────────────────────────────────────────────────
terraform destroy -auto-approve
az keyvault list-deleted --query "[].name" -o tsv # con purge_soft_delete_on_destroy = true no debe quedar nada
# ─── Solo Azure real ────────────────────────────────────────────────────────────
terraform apply -auto-approve -var validar_azure=true
# RBAC de verdad: la identidad web lee su secreto y nada más
az keyvault secret show --vault-name $KV -n mysql-moodle-password --query attributes.enabled # tú: Officer, OK
az login --identity --client-id $(terraform output -raw web_client_id) 2>/dev/null # desde la VM con esa identidad
az keyvault secret show --vault-name $KV -n mysql-moodle-password --query attributes.enabled # OK
az keyvault secret show --vault-name $KV -n demo-en-claro # ForbiddenByRbac: ámbito por secreto
# IMDS a mano desde la VM (lo que hace cloud-init):
curl -s -H 'Metadata: true' "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net&client_id=$CID" | jq -r .access_token | cut -c1-20
# Auditoría (página 14): cada lectura queda en AZKVAuditLogs con identidad, IP y resultado
az keyvault certificate show --vault-name $KV -n tls-lab --query "{caduca:attributes.expires, renueva:policy.lifetimeActions[0].trigger.daysBeforeExpiry}"
9. Errores comunes¶
⚠️ Solución de problemas
Mensaje o síntoma Causa y solución ForbiddenByRbac al crear el primer secreto El rol se asignó hace segundos; RBAC tarda hasta unos minutos. time_sleeptras elrole_assignment, o reintenta. Si persiste: el vault tiene RBAC activado pero tu identidad solo tiene Contributor (gestiona el vault, no lee su contenido)El plan alterna: crea y borra la misma access_policy(original)Política embebida en el vault y recurso azurerm_key_vault_access_policya la vez. Elige uno. Mejor:rbac_authorization_enabled = truey ningúnaccess_policyVaultAlreadyExists / ConflictError: a vault with this name was recently deleted El nombre es único global y el vault anterior está en borrado suave. recover_soft_deleted_key_vaults = trueen el provider, oaz keyvault purge(si no hay purge protection)terraform destroyfalla en el vault de pro y no se puede purgarCorrecto: purge_protection_enabledes irreversible y bloquea la purga hasta que pasesoft_delete_retention_days. Es lo que quieres en pro. En dev, no lo actives"value": conflicts with value_wo Un secreto se escribe con valueo convalue_wo, no ambos. Al migrar de uno a otro, el secreto se recrea (nueva versión en Key Vault, sin pérdida)Cambié la contraseña efímera y el plandice No changesEsperado: Terraform no guarda el valor, así que no puede compararlo. Solo value_wo_versiondispara la reescritura. SúbelaInvalid use of ephemeral value Intentas poner un efímero en un output, un atributo normal o un data. Solo argumentos_wo, providers, provisioners, otros efímeros y locals. Si el destino no tiene_wo, lee la nota de 11.4"Uso datapara no exponer el secreto" (original)Falso: el datase guarda íntegro en el estado en cada refresh.ephemeral "azurerm_key_vault_secret"hacia un argumento_woCon network_acls default_action = "Deny", Terraform ya no puede escribir secretosTe has cerrado la puerta a ti mismo. El runner necesita estar en ip_rules, en una subred con service endpoint, o (pro) llegar por private endpoint desde un runner autoalojado (página 12)IMDS devuelve 400 Multiple user assigned identities exist La VM tiene más de una identidad y no indicas cuál. Añade client_id=a la petición del token (el script de 11.5 ya lo hace)La VM lee el secreto del vault pero MySQL rechaza la contraseña Versiones desalineadas: el secreto se rotó y MySQL no (o al revés). Ambos _wo_versiondeben apuntar a la misma variable/local. Comprueba conaz keyvault secret list-versionsEl Application Gateway no arranca: KeyVaultSecretNotFound o SecretAccessForbidden Tres causas: la identidad del gateway no tiene Secrets User (lee el PFX como secreto, no como certificado); network_aclsen Deny sinbypass = "AzureServices"; o usassecret_idcon versión y el certificado se ha renovado. Usaversionless_secret_idEl certificado autofirmado se renueva pero el navegador sigue mostrando el antiguo El gateway sondea el vault cada 4 horas. Para forzar: az network application-gateway stop/start, o espera. En pro con CA integrada, planifica la renovación con > 30 días de margenSecret exceeds maximum size 25 KB es el límite. Un PFX grande, un fichero de configuración o un JSON de credenciales no van como secreto: certificado gestionado, cuenta de storage privada o dividir Defender / Policy marca Secrets should have expiration date Sin expiration_datela política de la organización lo señala. Pónsela alineada con la rotación (11.6) yignore_changespara que elplanno la mueva a diarioEl secreto aparece en el log del pipeline Alguien hizo echo,terraform outputo-varcon el valor. Con la arquitectura de esta página el pipeline nunca lo toca: Terraform lo genera y lee de forma efímera. Si aparece, rota (-replace=time_rotating.mysql) y borra el logEn Topaz: el secreto se crea pero az keyvault secret showdevuelve error de DNSEl plano de datos usa <vault>.vault.azure.net; el emulador lo resuelve a su propio endpoint solo si la CLI está configurada con el cloud Topaz (az cloud show). Revisa~/.azure/clouds.configy la variableAZURE_KEYVAULT_DNS_SUFFIXsegún la versión del emuladorEn Topaz: role assignment aplica pero la identidad puede leer cualquier secreto Esperado: el emulador almacena las asignaciones pero no evalúa RBAC en el plano de datos. El ámbito por secreto solo se comprueba en Azure real (bloque final de 11.8)
10. Autoevaluación¶
- ¿Cuántas copias de la contraseña crea
value = "SuperSecretPassword123!"antes de llegar al vault? Cuatro: el fichero.tf, el historial de Git, la salida delplany el estado.sensitivesolo oculta la pantalla, no el JSON del estado. - ¿Por qué no mezclar
access_policyembebido conazurerm_key_vault_access_policy? Ambos gestionan la misma lista: cadaapplyborra lo que el otro creó. Y ninguno de los dos es el modelo actual:rbac_authorization_enabled = trueconazurerm_role_assignment. - ¿Qué ventaja da RBAC sobre las políticas de acceso además de la coherencia?
Ámbito por secreto (
resource_versionless_id): la VM de Moodle lee su contraseña y nada más. Herencia desde grupo de recursos y suscripción, y activación temporal con PIM. - ¿Qué hace
value_woy qué papel tienevalue_wo_version?value_woenvía el valor a Azure sin guardarlo en el plan ni en el estado. Como Terraform no puede comparar lo que no guarda,value_wo_versiones lo único que dispara una reescritura. - ¿Por qué es falsa la nota "usa
datapara leer secretos sin exponerlos en el estado"? Undatase guarda íntegro en el estado, valor incluido, en cada refresh. La alternativa esephemeral "azurerm_key_vault_secret"hacia un argumento_wodel consumidor. - ¿Basta con que el origen sea efímero para que el secreto no toque el estado?
No. Si el destino es un atributo normal (
administrator_password), ese atributo se persiste. Ambos extremos deben ser transitorios:ephemeraly_wo. Si el destino no tiene_wo, Terraform rechaza el efímero y hay que aceptar el estado como copia, protegiendo el backend y rotando. - ¿Qué tres datos pasa Terraform a la VM de Moodle, y cuál no?
La URI del vault, el
client_idde la identidad y el nombre del secreto, todos públicos. La contraseña no: la VM la pide a IMDS con su identidad y la escribe en un fichero0600. - ¿Por qué user-assigned en lugar de system-assigned para el VMSS?
Existe antes que el VMSS, así el
role_assignmentestá propagado en el primer arranque decloud-init. Con system-assigned, la identidad nace con la VM y el primer intento de leer el secreto suele fallar. - ¿Cómo garantiza 11.6 que Key Vault y MySQL rotan juntos?
Los dos
_wo_versionapuntan al mismo local derivado detime_rotating; en elapplyen que cambia, ambos se reescriben con el mismo valor efímero. Para urgencias:-replace=time_rotating.mysql. - ¿Qué
key_optsnecesita una CMK y por qué no los seis del original? SolowrapKeyyunwrapKey: una CMK envuelve claves de datos. Dar tambiénsign,encryptodecryptamplía lo que un permiso comprometido puede hacer. - ¿Por qué emitir el certificado en el vault en vez de importar un PFX?
La clave privada nunca sale; la renovación es automática (
AutoRenew); y no hay contraseña de PFX en el código ni en el estado. El gateway lo lee porversionless_secret_idy recoge la renovación solo. - ¿Qué diferencia hay entre
soft_delete_retention_daysypurge_protection_enabled? El primero define cuánto tiempo es recuperable un vault o secreto borrado (7-90 días, siempre activo). El segundo impide purgar antes del plazo, a cualquiera, y es irreversible: pro sí, dev no. - ¿Cómo demuestras que un secreto no está en el estado?
terraform state pull | jqsobre los atributos del recurso:valueyvalue_woanull. Ygrepde un fragmento del valor real sobre eltfstate: cero coincidencias. - ¿Qué de esta página se prueba en Topaz y qué no?
Sí: vault, secretos (crear, versionar, leer con CLI), estado con y sin
_wo, rotación por versión. No: evaluación de RBAC, IMDS, MySQL, emisión de certificados y renovación. Eso se valida conplany se prueba en Azure real. - ¿Qué pasa si
network_aclsestá en Deny y el runner de CI/CD no está enip_rules? Terraform gestiona el vault (plano de gestión, por ARM) pero no puede escribir secretos (plano de datos, bloqueado). En pro, runner autoalojado en la VNet con private endpoint (páginas 12 y 13).
11. Referencias¶
- Azure Key Vault: visión general, claves, secretos y certificados y buenas prácticas
- Guía de RBAC para Key Vault (roles integrados, ámbito por objeto) y migración desde políticas de acceso
- Borrado suave y protección de purga
azurerm_key_vault,azurerm_key_vault_secret(value_wo),ephemeral azurerm_key_vault_secret,azurerm_key_vault_keyyazurerm_key_vault_certificate- Recursos efímeros en Terraform, argumentos write-only y
ephemeral random_password time_rotatingytime_sleep- Obtener un token con identidad gestionada desde una VM (IMDS) y referencias a Key Vault en App Service
- Rotación automática de claves y renovación de certificados
- Application Gateway con certificados de Key Vault
- Registro de auditoría de Key Vault (la tabla
AZKVAuditLogsde la página 14) - Azure Local Emulator (Topaz)