Módulo 3: Secrets Management

5. Manos a la obra: Secrets Manager con LocalStack

Descripción

Segundo y último recurso nuevo de secrets.tf: el usuario/contraseña de la API de aduanas, la credencial que la lección 3 asignó a Secrets Manager por dos razones concretas —es, literalmente, una credencial de autenticación, y es candidata natural a rotación periódica—. A diferencia de aws_ssm_parameter, que declara el valor en el mismo resource, Secrets Manager separa el contenedor del secreto (aws_secretsmanager_secret) de su contenido (aws_secretsmanager_secret_version) — dos recursos, no uno, y esta lección explica por qué esa separación existe antes de escribir el HCL.

Honestidad explícita, antes del primer comando. Igual que la lección 4: tflocal validate y tflocal plan corren de verdad, sin necesitar LocalStack. tflocal apply y awslocal secretsmanager get-secret-value quedan representativos, por la misma ausencia de LOCALSTACK_AUTH_TOKEN en este entorno.

Conexión con el módulo

Con esta lección, secrets.tf queda completo: los dos secretos que la lección 3 asignó a cada servicio, ambos declarados, ambos verificables con tflocal plan. La lección 6 explica el mecanismo de rotación que hace que Secrets Manager, y no SSM Parameter Store, sea el lugar correcto para esta credencial específica. La lección 8 cierra el módulo con el archivo completo, verificado de punta a punta.


Por qué dos recursos, no uno

Un aws_ssm_parameter mezcla, en un solo resource, la identidad del parámetro (name) y su contenido (value) — cambiar el valor significa modificar ese mismo resource. Secrets Manager separa deliberadamente esas dos cosas en dos recursos distintos:

  • aws_secretsmanager_secret — el contenedor: su nombre, su descripción, su política de recuperación. No tiene ningún valor secreto dentro.
  • aws_secretsmanager_secret_version — el contenido: el valor real, asociado a una versión específica del contenedor de arriba.

Esta separación no es una complicación innecesaria — es exactamente lo que hace posible la rotación nativa de la lección 6: cuando Secrets Manager rota un secreto, crea una versión nueva del mismo contenedor, sin recrear el contenedor entero, sin cambiar su ARN, sin que ninguna política IAM que apunte a ese ARN necesite actualizarse. Si el valor y el contenedor fueran el mismo resource, cada rotación implicaría destruir y recrear el secreto completo — perdiendo, en el camino, cualquier permiso o referencia que apuntara a él.


Paso 1 — Los dos recursos, en secrets.tf

Extiende el secrets.tf de la lección 4 con estos dos bloques nuevos, al final del archivo:

resource "aws_secretsmanager_secret" "customs_api_credentials" {
  name        = "andes-cargo/customs-api/credentials"
  description = "Username/password credential pair for the customs-clearance partner REST API"
}

resource "aws_secretsmanager_secret_version" "customs_api_credentials" {
  secret_id = aws_secretsmanager_secret.customs_api_credentials.id
  secret_string = jsonencode({
    username = var.customs_api_username
    password = var.customs_api_password
  })
}

Pieza por pieza:

  • name = "andes-cargo/customs-api/credentials", sin barra inicial — a diferencia de SSM Parameter Store, Secrets Manager no exige una jerarquía tipo filesystem; el / en el nombre es, aquí, solo una convención visual de agrupamiento, no una estructura que el servicio interprete.
  • secret_id = aws_secretsmanager_secret.customs_api_credentials.id — la referencia que conecta la versión con su contenedor. Terraform resuelve automáticamente el orden de creación: el contenedor primero, la versión después, sin que tengas que declararlo explícitamente con depends_on.
  • secret_string = jsonencode({...}) — Secrets Manager acepta tanto una cadena de texto simple como JSON estructurado en secret_string; para una credencial de dos campos relacionados —usuario y contraseña— JSON es el patrón correcto, exactamente el mismo que usarías para una cadena de conexión de base de datos con host, usuario y contraseña en un único secreto.

Y las dos variables nuevas, agregadas a variables.tf junto a la de la lección 4:

variable "customs_api_username" {
  description = "Username for the customs-clearance partner REST API (LocalStack test value)"
  type        = string
  sensitive   = true
  default     = "andes-cargo-test-user"
}

variable "customs_api_password" {
  description = "Password for the customs-clearance partner REST API (LocalStack test value)"
  type        = string
  sensitive   = true
  default     = "test-password-do-not-use-in-prod"
}

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, sobre el secrets.tf completo de esta lección más la lección 4; fíjate que ahora son tres recursos, no uno, porque el state sigue vacío — nada se aplicó todavía):

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_secretsmanager_secret.customs_api_credentials will be created
  + resource "aws_secretsmanager_secret" "customs_api_credentials" {
      + arn                            = (known after apply)
      + description                    = "Username/password credential pair for the customs-clearance partner REST API"
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "andes-cargo/customs-api/credentials"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 30
      + region                         = "us-east-1"
      + tags_all                       = (known after apply)

      + replica (known after apply)
    }

  # aws_secretsmanager_secret_version.customs_api_credentials will be created
  + resource "aws_secretsmanager_secret_version" "customs_api_credentials" {
      + arn                  = (known after apply)
      + has_secret_string_wo = (known after apply)
      + id                   = (known after apply)
      + region               = "us-east-1"
      + secret_arn           = (known after apply)
      + secret_id            = (known after apply)
      + secret_string        = (sensitive value)
      + secret_string_wo     = (write-only attribute)
      + version_id           = (known after apply)
      + version_stages       = (known after apply)
    }

  # 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: 3 to add, 0 to change, 0 to destroy.

Dos detalles reales, visibles solo corriendo el plan de verdad, no adivinándolo. Primero: recovery_window_in_days = 30, un valor que no declaraste en tu HCL — es el default del provider de AWS: al eliminar un secreto, Secrets Manager no lo borra al instante, lo retiene 30 días antes de la eliminación definitiva, exactamente como protección contra un destroy accidental (el mismo tipo de riesgo que TM-06 de THREAT-MODEL.md nombra para la tabla Shipments, aunque ahí la mitigación sea distinta — policy-as-code en el Módulo 4). Segundo: secret_string = (sensitive value), otra vez el mismo mecanismo de la lección 4 — Terraform nunca imprime el contenido de un atributo sensitive, sin importar que aquí sea un jsonencode() con dos campos adentro, no un valor simple.


Paso 3 — Aplicando y leyendo el secreto (representativo)

tflocal apply -auto-approve

Qué esperar (representativo — misma razón que la lección 4: sin LOCALSTACK_AUTH_TOKEN, LocalStack no responde en este entorno):

aws_ssm_parameter.customs_api_webhook_signing_key: Creating...
aws_secretsmanager_secret.customs_api_credentials: Creating...
aws_ssm_parameter.customs_api_webhook_signing_key: Creation complete after 0s [id=/andes-cargo/customs-api/webhook-signing-key]
aws_secretsmanager_secret.customs_api_credentials: Creation complete after 1s [id=arn:aws:secretsmanager:us-east-1:000000000000:secret:andes-cargo/customs-api/credentials-a1b2c3]
aws_secretsmanager_secret_version.customs_api_credentials: Creating...
aws_secretsmanager_secret_version.customs_api_credentials: Creation complete after 0s [id=arn:aws:secretsmanager:us-east-1:000000000000:secret:andes-cargo/customs-api/credentials-a1b2c3|AWSCURRENT]

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

El sufijo -a1b2c3 en el ARN es representativo y aleatorio por diseño — a diferencia de un parámetro de SSM, cuyo ARN es completamente predecible a partir de su name, Secrets Manager agrega seis caracteres alfanuméricos generados por el servicio al final de cada ARN de secreto, precisamente para que no sea adivinable a partir del nombre. En una aplicación real, ese sufijo es distinto en cada ejecución.

awslocal secretsmanager get-secret-value --secret-id andes-cargo/customs-api/credentials

Qué esperar (representativo — reconstruido campo por campo del comportamiento documentado de secretsmanager get-secret-value):

{
    "ARN": "arn:aws:secretsmanager:us-east-1:000000000000:secret:andes-cargo/customs-api/credentials-a1b2c3",
    "Name": "andes-cargo/customs-api/credentials",
    "VersionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "SecretString": "{\"username\":\"andes-cargo-test-user\",\"password\":\"test-password-do-not-use-in-prod\"}",
    "VersionStages": [
        "AWSCURRENT"
    ]
}

Fíjate en VersionStages: ["AWSCURRENT"] — no lo vas a construir de punta a punta hasta la lección 6, pero ya es visible aquí, en la primera versión de cualquier secreto: cada versión de un secreto de Secrets Manager lleva una o más etiquetas de staging (staging labels), y AWSCURRENT es la que marca "esta es la versión activa, la que cualquier lectura sin especificar versión va a devolver". La lección 6 explica qué pasa con esa etiqueta durante una rotación.

Nota que get-secret-value no necesita un flag equivalente a --with-decryption. Secrets Manager descifra automáticamente el valor para cualquier llamador con el permiso secretsmanager:GetSecretValue — a diferencia de SSM Parameter Store, donde el descifrado es un paso explícito y separado del permiso de lectura. Es una diferencia real de diseño entre los dos servicios, no un detalle menor: en SSM Parameter Store, alguien con ssm:GetParameter pero sin kms:Decrypt puede confirmar que un parámetro existe sin poder leer su valor; en Secrets Manager, el permiso de lectura y el de descifrado están fusionados en uno solo.


Errores comunes

Declarar secret_string dentro de aws_secretsmanager_secret, en vez de en aws_secretsmanager_secret_version (de la separación de recursos, Paso 1). Qué pasa: alguien, acostumbrado al patrón de un solo resource de aws_ssm_parameter, intenta poner secret_string directamente en el bloque aws_secretsmanager_secret. Cómo detectarlo: terraform validate falla con un error de argumento no reconocido (secret_string no es un argumento válido de aws_secretsmanager_secret). Cómo corregirlo: recuerda la separación contenedor/contenido de esta lección — el valor siempre va en un aws_secretsmanager_secret_version aparte, referenciando el contenedor por secret_id.

Usar una cadena simple en vez de jsonencode() para un secreto con múltiples campos (de estructura). Qué pasa: alguien concatena usuario y contraseña en una sola cadena de texto ("user:pass"), en vez de un objeto JSON, porque parece más simple. Cómo detectarlo: si el código que lee el secreto necesita parsear manualmente un separador arbitrario, en vez de deserializar JSON con una librería estándar. Cómo corregirlo: jsonencode({ username = ..., password = ... }) produce una cadena JSON válida que cualquier lenguaje puede parsear con su librería estándar de JSON, sin inventar un formato de separación propio — el patrón real que usarías para cualquier credencial con más de un campo, incluida una cadena de conexión de base de datos.

Confundir el recovery_window_in_days por defecto con "el secreto tarda 30 días en crearse" (de lectura del plan). Qué pasa: alguien, viendo recovery_window_in_days = 30 en el plan del Paso 2, asume que hay alguna demora de 30 días involucrada en crear el secreto. Cómo detectarlo: si tu expectativa después de un apply exitoso es que el secreto todavía no esté disponible para lectura. Cómo corregirlo: recovery_window_in_days gobierna cuánto tiempo Secrets Manager retiene un secreto después de eliminarlo —antes de borrarlo definitivamente—, no cuánto tarda en crearse. El apply del Paso 3 lo confirma: Creation complete after 1s, no 30 días.


Ejercicios

Ejercicio 1 — Agrega un tercer campo al secreto. Sin correr ningún comando, escribe cómo quedaría aws_secretsmanager_secret_version.customs_api_credentials si la API de aduanas también requiriera un campo account_id además de username y password. ¿Qué variable nueva necesitarías declarar en variables.tf?

Ver solución
resource "aws_secretsmanager_secret_version" "customs_api_credentials" {
  secret_id = aws_secretsmanager_secret.customs_api_credentials.id
  secret_string = jsonencode({
    username   = var.customs_api_username
    password   = var.customs_api_password
    account_id = var.customs_api_account_id
  })
}

Y en variables.tf, una nueva variable sensitive (o no, dependiendo de si un account_id cuenta como secreto bajo la prueba del REDACTED de la lección 1 — en muchos sistemas reales un identificador de cuenta no es secreto por sí solo, solo la combinación con la contraseña lo es; ese matiz depende del sistema externo real):

variable "customs_api_account_id" {
  description = "Account identifier for the customs-clearance partner REST API"
  type        = string
  default     = "andes-cargo-001"
}

Ejercicio 2 — Explica el sufijo aleatorio del ARN a un compañero que nunca usó Secrets Manager. Un compañero, acostumbrado a SSM Parameter Store (donde el ARN es 100% predecible del name), pregunta por qué el ARN de un secreto de Secrets Manager tiene caracteres extra al final que no declaró en ningún lado. Respóndele en dos o tres frases.

Ver solución

Una respuesta completa suena, más o menos, así: "Es una decisión de diseño de Secrets Manager: cada secreto recibe seis caracteres generados por el servicio al final de su ARN, precisamente para que el ARN completo no sea 100% adivinable solo con el nombre — alguien que conoce el nombre del secreto no puede construir su ARN completo sin haberlo consultado primero. SSM Parameter Store no hace esto; su ARN es enteramente derivable del name. Ninguno de los dos diseños es 'mejor' en abstracto, son decisiones distintas de cada servicio."

Ejercicio 3 — Compara el permiso de lectura entre ambos servicios. Un colega que solo trabajó con SSM Parameter Store pregunta si necesita, para leer el secreto de esta lección, tanto secretsmanager:GetSecretValue como kms:Decrypt, igual que con --with-decryption en SSM. ¿Es correcto?

Ver solución

No es correcto — y la lección lo señaló explícitamente. Secrets Manager fusiona el permiso de lectura y el de descifrado en uno solo: alguien con secretsmanager:GetSecretValue sobre el ARN correcto recibe el valor ya descifrado, sin necesitar kms:Decrypt como un permiso separado y explícito (aunque, por debajo, Secrets Manager sigue usando KMS para el cifrado en reposo). Esto es una diferencia real de diseño entre los dos servicios: en SSM Parameter Store, alguien puede tener permiso de confirmar que un parámetro existe sin poder ver su valor descifrado; en Secrets Manager, esa distinción de dos niveles no existe.


Resumen y siguiente paso

En esta lección completaste secrets.tf con el segundo secreto de Andes Cargo: aws_secretsmanager_secret más aws_secretsmanager_secret_version, la separación contenedor/contenido que hace posible la rotación nativa de la lección siguiente. Corriste tflocal validate y tflocal plan de verdad, sobre los tres recursos combinados de las lecciones 4 y 5, y viste dos detalles reales del comportamiento de Secrets Manager que solo aparecen ejecutando el plan de verdad: el recovery_window_in_days por defecto, y el enmascarado de secret_string como valor sensible. Viste, de forma representativa, la aplicación y lectura del secreto, incluida la etiqueta AWSCURRENT que la lección 6 va a desarrollar.

Antes de avanzar deberías poder: explicar por qué Secrets Manager separa contenedor y contenido en dos recursos, y qué hace posible esa separación; escribir de memoria un aws_secretsmanager_secret_version con jsonencode() para un secreto de múltiples campos; y explicar la diferencia de permisos de lectura entre SSM Parameter Store y Secrets Manager.

La lección 6 desarrolla, con precisión técnica, el mecanismo que la etiqueta AWSCURRENT de esta lección hizo visible por primera vez: el ciclo completo de rotación de Secrets Manager — AWSPREVIOUS/AWSCURRENT/AWSPENDING — nombrado con exactitud, sin ejecutarlo de punta a punta.

Recursos

  1. Terraform Registry — aws_secretsmanager_secret — documentación oficial del recurso contenedor.
  2. Terraform Registry — aws_secretsmanager_secret_version — documentación oficial del recurso de contenido, incluida la interacción con secret_string.
  3. AWS CLI — secretsmanager get-secret-value — referencia completa del comando del Paso 3.
  4. AWS Docs — Managing multiple versions of a secret — introducción oficial a las etiquetas de staging (AWSCURRENT, AWSPENDING, AWSPREVIOUS), desarrolladas a fondo en la lección 6.