Módulo 3: Secrets Management

4. Manos a la obra: SSM Parameter Store con LocalStack

Descripción

Primer recurso HCL nuevo de este módulo: aws_ssm_parameter, tipo SecureString, declarando la firma HMAC del webhook de aduanas que la lección 3 asignó a este servicio. Vas a escribir el archivo, correr tflocal validate y tflocal plan de verdad —sin necesitar que LocalStack esté respondiendo—, y ver la forma exacta que tomaría la lectura del parámetro ya aplicado.

Honestidad explícita, antes del primer comando. Este entorno de escritura no tiene un LOCALSTACK_AUTH_TOKEN exportado —el mismo límite que ya viste en el Módulo 1, lección 4, y en cicd-and-gitops-on-aws-guide, Módulo 4, lección 7—, así que el contenedor de LocalStack no arranca aquí. tflocal validate y tflocal plan corren de verdad, sin ninguna llamada de red: son la parte ejecutable real de esta lección. tflocal apply y el comando awslocal de lectura quedan representativos, con la salida reconstruida campo por campo a partir de lo que la documentación oficial de AWS confirma que produce cada comando.

Conexión con el módulo

La lección 3 estableció el criterio: un valor único, sin necesidad de rotación automática, es candidato de SSM Parameter Store. Esta lección construye exactamente ese caso. La lección 5 hace el mismo recorrido con Secrets Manager, para el segundo secreto de Andes Cargo.


Paso 1 — El archivo secrets.tf, primera parte

En la raíz de andes-cargo-infra/, junto a THREAT-MODEL.md y RISK-MAP.md del Módulo 1, crea secrets.tf:

# secrets.tf -- Andes Cargo secrets management (Module 3)
# Replaces the flat .secrets file pattern (see THREAT-MODEL.md, finding TM-05).

resource "aws_ssm_parameter" "customs_api_webhook_signing_key" {
  name        = "/andes-cargo/customs-api/webhook-signing-key"
  description = "HMAC signing key used to verify inbound webhook calls from the customs-clearance partner API"
  type        = "SecureString"
  value       = var.customs_api_webhook_signing_key

  tags = {
    Project = "andes-cargo"
  }
}

Pieza por pieza:

  • name = "/andes-cargo/customs-api/webhook-signing-key" — SSM Parameter Store organiza parámetros en una jerarquía tipo filesystem, separada por /. Empezar con /andes-cargo/ no es cosmético: te permite, más adelante, otorgar acceso con un solo patrón (ssm:GetParameter sobre arn:aws:ssm:*:*:parameter/andes-cargo/*) en vez de listar cada parámetro uno por uno.
  • type = "SecureString" — el único de los tres tipos (String, StringList, SecureString) que cifra el valor con KMS antes de guardarlo. Sin este tipo, el valor quedaría en texto plano dentro de SSM Parameter Store — el mismo problema de fondo que la lección 2 nombró, solo que ahora dentro de un servicio gestionado en vez de un archivo.
  • value = var.customs_api_webhook_signing_key — nunca un valor literal en el HCL. La variable es lo que hace posible que este mismo archivo, sin cambiar una sola línea, funcione tanto contra LocalStack (con un valor dummy) como contra una cuenta AWS real (con el valor real, provisto por fuera del archivo — una variable de entorno, un flujo de CI/CD con OIDC, nunca hardcodeado).

Ahora la variable, en variables.tf —el archivo que terraform-and-iac-guide, Módulo 3, ya dejó existente en este proyecto; esta lección agrega tres declaraciones nuevas al final, no crea el archivo desde cero—:

variable "customs_api_webhook_signing_key" {
  description = "HMAC signing key for the customs-clearance partner webhook (LocalStack test value)"
  type        = string
  sensitive   = true
  default     = "test-webhook-signing-key-do-not-use-in-prod"
}

sensitive = true es el detalle que vale la pena notar con cuidado: no cambia cómo Terraform guarda el valor —sigue en el state, sin cifrar por Terraform mismo—, pero sí cambia qué muestra en la terminal. Vas a ver el efecto exacto en el Paso 2.


Paso 2 — tflocal validate y tflocal plan, reales

tflocal validate

Qué esperar (literal — ejecutado para escribir esta lección):

Success! The configuration is valid.
tflocal plan

Qué esperar (literal — ejecutado para escribir esta lección; fíjate en value = (sensitive value), la línea que confirma el efecto de sensitive = true de la variable):

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_ssm_parameter.customs_api_webhook_signing_key will be created
  + resource "aws_ssm_parameter" "customs_api_webhook_signing_key" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + description    = "HMAC signing key used to verify inbound webhook calls from the customs-clearance partner API"
      + has_value_wo   = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = (known after apply)
      + name           = "/andes-cargo/customs-api/webhook-signing-key"
      + region         = "us-east-1"
      + tags           = {
          + "Project" = "andes-cargo"
        }
      + tags_all       = {
          + "Project" = "andes-cargo"
        }
      + tier           = (known after apply)
      + type           = "SecureString"
      + value          = (sensitive value)
      + value_wo       = (write-only attribute)
      + version        = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Dos cosas para leer con cuidado. Primera: este plan calculó, de forma completamente correcta, que va a crear un recurso — sin que LocalStack respondiera en ningún momento. La razón es la misma que ya confirmó cicd-and-gitops-on-aws-guide, Módulo 4, lección 7: crear un recurso nuevo, sobre un state vacío, no necesita ninguna llamada de red real; el providers.tf heredado de terraform-and-iac-guide ya tiene skip_requesting_account_id = true y el resto de los flags skip_* que hacen esto posible. Segunda: value = (sensitive value) — Terraform nunca imprime el valor real de un atributo marcado sensitive en ningún plan ni output de consola, sin importar si ese valor es un secreto real o un dummy de prueba como en este caso. Esto no reemplaza a SSM Parameter Store —el valor sigue en texto plano dentro del state, sin cifrar por Terraform—, pero sí evita el error más común de exposición accidental: que un valor sensible termine pegado en un log de CI/CD legible por cualquiera.


Paso 3 — Aplicando y leyendo el parámetro (representativo)

Con LOCALSTACK_AUTH_TOKEN exportado y LocalStack corriendo, tflocal apply aplicaría este plan sin ningún paso adicional:

tflocal apply -auto-approve

Qué esperar (representativo — este entorno de escritura no tiene LOCALSTACK_AUTH_TOKEN exportado; la salida reconstruye lo que produciría tflocal apply sobre este HCL, con el mismo formato que ya confirmaron el Módulo 1 y el Módulo 2 de esta guía contra recursos reales de Andes Cargo):

aws_ssm_parameter.customs_api_webhook_signing_key: Creating...
aws_ssm_parameter.customs_api_webhook_signing_key: Creation complete after 0s [id=/andes-cargo/customs-api/webhook-signing-key]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Y la lectura, con el flag que descifra el valor:

awslocal ssm get-parameter --name /andes-cargo/customs-api/webhook-signing-key --with-decryption

Qué esperar (representativo — reconstruido campo por campo del comportamiento documentado de ssm get-parameter; el Version y el ARN son los que produciría la primera aplicación de este HCL contra la cuenta fija 000000000000):

{
    "Parameter": {
        "Name": "/andes-cargo/customs-api/webhook-signing-key",
        "Type": "SecureString",
        "Value": "test-webhook-signing-key-do-not-use-in-prod",
        "Version": 1,
        "ARN": "arn:aws:ssm:us-east-1:000000000000:parameter/andes-cargo/customs-api/webhook-signing-key",
        "DataType": "text"
    }
}

El flag --with-decryption no es opcional para un SecureString. Sin él, get-parameter devuelve el valor todavía cifrado —una cadena de texto sin sentido, no un error—, porque la decisión de si quien pregunta tiene derecho a ver el valor descifrado es, precisamente, lo que ese flag activa: el llamador necesita, además de ssm:GetParameter, el permiso kms:Decrypt sobre la clave que cifró el parámetro. Es el mismo principio de mínimo privilegio del Módulo 2, aplicado ahora en dos capas en vez de una.


Por qué terraform.tfvars nunca lleva el valor real

terraform-and-iac-guide, Módulo 3, ya dejó terraform.tfvars y dev.tfvars como parte de este proyecto, listados en *.tfvars dentro de .gitignore desde el Módulo 1. Es tentador, la primera vez que trabajas con una variable sensitive, poner el valor real ahí en vez de dejarlo como default en variables.tf — después de todo, el archivo está gitignoreado. No lo hagas, ni siquiera con el archivo protegido. La lección 2 de este módulo ya explicó por qué: gitignorado no es sinónimo de con control de acceso propio, con auditoría, ni con rotación — solo resuelve que Git no lo rastree. Contra LocalStack, con un valor dummy como el de esta lección, no hay ningún riesgo real. Contra una cuenta AWS real, el valor de una variable sensitive correspondiente a una credencial real debería venir de una fuente que sí tenga esas tres propiedades —en producción, del propio SSM Parameter Store o Secrets Manager, leído en tiempo de apply con un data source, nunca escrito a mano en un archivo local—.


Errores comunes

Olvidar --with-decryption y asumir que el parámetro está mal creado (de lectura del comando). Qué pasa: alguien corre awslocal ssm get-parameter --name /andes-cargo/customs-api/webhook-signing-key sin el flag, ve un valor cifrado ilegible, y concluye que el apply falló o guardó algo incorrecto. Cómo detectarlo: si el Value que ves no se parece en nada al que declaraste en variables.tf. Cómo corregirlo: es el comportamiento esperado y correcto de un SecureString sin --with-decryption — el parámetro está perfectamente bien creado; lo que falta es pedir explícitamente el descifrado, con el permiso kms:Decrypt que eso implica.

Poner el valor real de un secreto directamente en secrets.tf, "solo para probar rápido" (de hábito, contradice la lección 2). Qué pasa: alguien, apurado, escribe value = "el-valor-real-de-la-firma" directamente en el resource, pensando en volver después y moverlo a una variable. Cómo detectarlo: si secrets.tf tiene algún valor literal en vez de una referencia var.*. Cómo corregirlo: ese archivo se versiona en Git — cualquier valor literal ahí repite exactamente el antipatrón de la lección 2, ahora dentro del propio código de infraestructura en vez de un archivo .secrets aparte. La variable, con su default dummy para LocalStack, es la única forma correcta, desde la primera línea que escribes.

Confundir sensitive = true con cifrado real (conceptual). Qué pasa: alguien, después de ver value = (sensitive value) en el plan, asume que Terraform ya está protegiendo ese valor de la misma forma que SecureString lo hace en SSM. Cómo detectarlo: si tu razonamiento de seguridad para un valor sensitive se detiene ahí, sin considerar cómo queda guardado en terraform.tfstate. Cómo corregirlo: sensitive = true solo controla qué se imprime en la consola y en los logs de CI/CD — el valor sigue existiendo, sin cifrar por Terraform, dentro del archivo de state. Proteger el state mismo (con un backend remoto cifrado, fuera del alcance de esta guía) es un problema distinto y complementario, no resuelto por este flag.


Ejercicios

Ejercicio 1 — Predice el plan de un segundo parámetro. Sin correr ningún comando, escribe el bloque resource "aws_ssm_parameter" que declararía un segundo parámetro, /andes-cargo/customs-api/environment, tipo String (no SecureString), con valor fijo "sandbox". ¿Necesita una variable sensitive? Justifica.

Ver solución
resource "aws_ssm_parameter" "customs_api_environment" {
  name        = "/andes-cargo/customs-api/environment"
  description = "Which environment of the customs-clearance partner API this deployment targets"
  type        = "String"
  value       = "sandbox"
}

No necesita una variable sensitive — aplicando la prueba del REDACTED de la lección 1, "sandbox" no es un secreto: publicarlo no le da a nadie ninguna capacidad nueva sobre ningún sistema. Es exactamente el tipo de valor que la lección 3 clasificó como configuración pura, candidato de String, no de SecureString.

Ejercicio 2 — Explica por qué el plan de esta lección no necesitó LocalStack corriendo, en tus propias palabras. Un colega, viendo que el plan del Paso 2 tuvo éxito a pesar de que LOCALSTACK_AUTH_TOKEN no está exportado, pregunta cómo es posible. Respóndele sin copiar la explicación de esta lección.

Ver solución

Una respuesta completa suena, más o menos, así: "Calcular un plan de creación, sobre un recurso que todavía no existe en el state, no requiere que Terraform confirme nada contra el servicio real — solo necesita saber qué HCL declaraste. Los flags skip_credentials_validation y skip_requesting_account_id del providers.tf de este proyecto le dicen al provider que no intente verificar nada contra AWS antes de calcular ese plan. Donde sí haría falta una conexión real es en el apply —ahí Terraform necesita efectivamente crear el recurso contra el servicio—, y ahí es exactamente donde este entorno se queda sin poder ejecutar de verdad, por la falta del token."

Ejercicio 3 — Calcula el ARN de un tercer parámetro. Sin correr ningún comando, escribe el ARN completo que tendría un parámetro llamado /andes-cargo/customs-api/retry-limit, aplicado en la cuenta fija de esta guía y su región fija. Usa como referencia el ARN del Paso 3.

Ver solución

arn:aws:ssm:us-east-1:000000000000:parameter/andes-cargo/customs-api/retry-limit — el patrón del ARN de un parámetro de SSM es siempre arn:aws:ssm:<región>:<cuenta>:parameter<nombre-completo-con-barra-inicial>, con la cuenta (000000000000) y la región (us-east-1) fijas en todo este ecosistema desde aws-core-services-guide.


Resumen y siguiente paso

En esta lección declaraste el primer recurso de secrets.tf: un aws_ssm_parameter tipo SecureString, con su valor viniendo siempre de una variable sensitive, nunca de un literal. Corriste tflocal validate y tflocal plan de verdad, sin necesitar LocalStack corriendo, y confirmaste el efecto exacto de sensitive = true en la salida de Terraform. Viste, de forma representativa, cómo se vería la aplicación real y la lectura con --with-decryption — y por qué ese flag no es opcional para un SecureString.

Antes de avanzar deberías poder: escribir de memoria un aws_ssm_parameter tipo SecureString con su variable sensitive correspondiente; explicar por qué sensitive = true no reemplaza al cifrado real de SecureString; y explicar por qué --with-decryption es necesario, no cosmético.

La lección 5 declara el segundo recurso de secrets.tf: el usuario/contraseña de la API de aduanas, esta vez en Secrets Manager, con aws_secretsmanager_secret y aws_secretsmanager_secret_version.

Recursos

  1. AWS Docs — Create a Systems Manager parameter — referencia oficial de los tres tipos de parámetro (String, StringList, SecureString).
  2. AWS CLI — ssm get-parameter — referencia completa del comando del Paso 3, incluido el flag --with-decryption.
  3. Terraform Registry — aws_ssm_parameter — documentación oficial del recurso declarado en esta lección.
  4. Terraform Docs — Sensitive Input Variables — el mecanismo exacto detrás de sensitive = true, citado en esta lección.