Enmilocalfunciona

Thoughts, stories and ideas.

Gestión de secretos con Terraform y Mozilla SOPS en Azure Cloud

Publicado por Manuel García de Vinuesa Gómez el

DevOpsDevSecOpsInfraestructuraInfraestructura como CódigoTerraformCloudAzureMozilla SOPS

Introducción

En este artículo vamos a presentar como podemos gestionar la gestión de secretos (datos sensibles) dentro de Azure Cloud usando Terraform como herramienta de IAC (Infraestructure As Code) junto con Mozilla SOPS. SOPS nos va a permitir mantener nuestros datos sensibles en el repositorio de código (repositorio de IAC) sin exponer la información sensible.

cabecera---Enmilocalfunciona---Terraform---1400x400px-1

¿Qué es Terraform?

Terraform es un software de infraestructura como código (infrastructure as code) desarrollado por HashiCorp. Permite a los usuarios definir y configurar la infraestructura de un centro de datos en un lenguaje de alto nivel, generando un plan de ejecución para desplegar la infraestructura en proveedores de servicio tales como AWS, IBM Cloud, Google Cloud Platform, Microsoft Azure...

Además a través de sus providers nos permite integrarnos con diferentes APIs de servicio como son bases de datos, cluster de kubernetes, etc.

Aunque para seguir este post no es necesaria experiencia previa en terraform, no vamos a entrar en detalles sobre lo que hace cada comando o cómo funciona terraform.

Autenticación mediante Service Principal

Terraform soporta diferentes capacidades de login contra una subscripción de Azure. En nuestro caso vamos a optar por la opción de Service Principal.

Se conoce como Service Principal a una aplicación dentro de Azure Active Directory (AAD) la cual podemos usar para autenticarnos usando las variables client_id, client_secret, tenant_id y por supuesto el subscription_id.

No vamos a entrar en detalles de cómo gestionar un Service Principal para nuestra subscripción de Azure, ya que sale del alcance de este post. Toda la información la podéis encontrar aquí:

¿Qué es Mozilla SOPS?

Mozilla SOPS (Secrets OPerationS), enlace, es una herramienta que nos permite cifrar y descifrar el contenido de archivos y nos permite integrarnos de manera sencilla con los diferentes proveedores de almacenes de claves en cloud.

Dicho software es muy interesante por cómo trabaja con ficheros YAML y JSON, ya que lo que cifra es el contenido de los valores, y nunca las claves, por lo que, como veremos a continuación, nos permite conocer el contenido del fichero (qué tiene) sin exponer el contenido sensible.

Además, dentro de terraform tenemos un provider que nos va a permitir integrarnos con dicho software dentro de nuestra definición de recursos de terraform.

Implementación

La idea detrás de esta implementación es sencilla, nosotros tendremos inicialmente en un fichero EN CLARO la información que va a nutrir nuestros secretos, dicho fichero no vamos a subirlo a ningún repositorio de código (en nuestro ejemplo sí). Pero lo vamos a necesitar para generar el fichero encriptado.

Con dicho fichero y con el CLI de SOPS, generaremos un nuevo fichero con los valores encriptados. Una de las principales ventajas de SOPS son las capacidades de integración, en este caso con Azure, y por tanto nos va a permitir encriptar dicho fichero usando una clave gestionada directamente por Azure.

Por ello, lo primero que deberemos gestionar es la creación de dicha clave, para ya posteriormente empezar a trabajar con SOPS.

Preparación

Para poder trabajar con terraform y azure debemos definir el provider correspondiente. Además, el estado de terraform lo vamos a almacenar en una storage account de Azure. Para ello definimos el backend y el provider:

terraform {
  
  backend "azurerm" {
    resource_group_name  = "<RESOURCE_GROUP_NAME>"
    storage_account_name = "<STORAGE_ACCOUNT_NAME>"
    container_name       = "<CONTAINER_NAME>"
    key                  = "sops.terraform.tfstate"
  }

  
}

provider "azurerm" {
  features {}
}

En el siguiente enlace tenéis en más detalle todo lo relativo al backend de azurerm.

Antes de lanzar nuestros comandos de terraform deberemos importar las credenciales asociadas al service principal, para ello deberemos exportarlas:

export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_CLIENT_SECRET="12345678-0000-0000-0000-000000000000"
export ARM_TENANT_ID="10000000-0000-0000-0000-000000000000"
export ARM_SUBSCRIPTION_ID="20000000-0000-0000-0000-000000000000"

Para vuestras pruebas en local no tenéis porque informar del backend para guardar el estado en remoto, pero tiene ciertas ventajas, además de permitir su uso en entornos de integración continua donde los agentes son efímeros.

Creación de la clave de encriptación

Antes de generar la clave vía terraform debemos cercionarnos que nuestro service principal tiene capacidades de trabajar con claves (encriptar y desencriptar), para ello que nuestro Key Vault -> Access Policies debemos revisar lo siguiente:

Las políticas deberían ser al menos:

  • Key Management: Get, Update, Delete, Recover
  • Cryptographic Operations: Decrypt, Encrypt
  • Rotation Policy Operations: Get, Set

Una vez gestionado los permisos nos basta con crear la clave, usando terraform sería de la siguiente manera:


data "azurerm_key_vault" "keyvault" {
  name = var.key_vault_name
  resource_group_name = var.keyvault_resource_group_name
}

resource "azurerm_key_vault_key" "sops_key" {
    name         = "sops-key"
    key_vault_id = data.azurerm_key_vault.keyvault.id
    key_type     = "RSA"
    key_size     = 2048

    key_opts = [
        "decrypt",
        "encrypt"
    ]

}

output "sops_key" {
  value = azurerm_key_vault_key.sops_key.id
}

En dicho snippet de código encontramos:

  • Un DataSource de terraform azurerm_key_vault para obtener los datos del Vault, estamos dando por hecho que el vault está creado y tan sólo necesitamos obtener el ID para crear los secretos.
  • Un Recurso para generar la clave azurerm_key_vault_key que usaremos para cifrar y descifrar el contenido via SOPS.
  • Un Output para ver el ID de la clave y poder usarlo con el SOPS CLI.

Los recursos terraform que usamos en este caso son:

Tras ello ejecutamos los siguientes comandos:

terraform init
terraform validate
terraform plan -out thePlan.plan -var-file="inputs.tfvars"

Con lo que le diremos a terraform que compare nuestro estado actual (tfstate) con lo que estamos intentando crear. En este caso en el fichero inputs.tfvars deberemos incluir las siguientes variables:

# Name of vault
key_vault_name = <VAULT_NAME>
keyvault_resource_group_name = <VAULT_RESOURCE_GROUP_NAME>

Una vez ejecutado el plan, obtenemos el siguiente mensaje en el log:


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:

  # azurerm_key_vault_key.sops_key will be created
  + resource "azurerm_key_vault_key" "sops_key" {
      + curve                   = (known after apply)
      + e                       = (known after apply)
      + id                      = (known after apply)
      + key_opts                = [
          + "decrypt",
          + "encrypt",
        ]
      + key_size                = 2048
      + key_type                = "RSA"
      + key_vault_id            = "/subscriptions/<VAULT_ID>"
      + n                       = (known after apply)
      + name                    = "sops-key"
      + public_key_openssh      = (known after apply)
      + public_key_pem          = (known after apply)
      + resource_id             = (known after apply)
      + resource_versionless_id = (known after apply)
      + version                 = (known after apply)
      + versionless_id          = (known after apply)
      + x                       = (known after apply)
      + y                       = (known after apply)

      + rotation_policy {
          + expire_after         = (known after apply)
          + notify_before_expiry = (known after apply)

          + automatic {
              + time_after_creation = (known after apply)
              + time_before_expiry  = (known after apply)
            }
        }
    }

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

Por lo que tan sólo estamos creando un componente, en este caso la clave. El output no se considera infraestructura aunque se guarda en el estado. Tras ello, aplicamos:

terraform apply "thePlan.plan"

Y comprobamos que todo se ha creado correctamente y que tenemos en el output el ID de la clave:

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

Outputs:

sops_key = "https://<VAULT_URL>/keys/sops-key/e5b9c8a1f0654cdbbabea8233b4692a1"

Y de esta manera ya tenemos generada la clave dentro de Azure. De hecho podríamos consultarla a través del AZURE CLI:

manu@AT-5CD2144YCB:/mnt/c/Dev/github/mgvinuesa/azure-secrets-terraform-and-sops/terraform$ az keyvault key show --name sops-key --vault-name <VAULT_NAME>
{
  "attributes": {
    "created": "2023-05-08T08:01:38+00:00",
    "enabled": true,
    "expires": null,
    "exportable": false,
    "notBefore": null,
    "recoverableDays": 90,
    "recoveryLevel": "Recoverable",
    "updated": "2023-05-08T08:01:38+00:00"
  },
  "key": {
    "crv": null,
    "d": null,
    "dp": null,
    "dq": null,
    "e": "AQAB",
    "k": null,
    "keyOps": [
      "decrypt",
      "encrypt"
    ],
    "kid": "https://<VAULT_URL>/keys/sops-key/e5b9c8a1f0654cdbbabea8233b4692a1",
    "kty": "RSA",
    "n": "+LrQ4bgZG12kHjBjzIGL1/javRJJLy7l9NFccwNMicvCg9FyIh/kz86+nFr+4oMH7IZtqb85qTsNOhDM7kdeAejO3BJkCHpeVCdCowr5rq6Eu039+4AhuAvZuFnInZlMMm4nSdGg7xNzV0ZHTMni+6Mif3LfIx+q63H2LXxd8Bb3ktmt/zKN4tDfNmQmVHckibhmjQb84ZAhsksoZLpyh1D7Q5EzGBv1hywDNG33amfjsNyCw6ajgukoSXhS4Wjsk5jIc6H0awG3FSM4RGQytiGShmh9JPneh6IUQ/WFQyegOLWR6gK5dnjFAqyUFlfKBqdps0oimDQAElWElAzxaQ==",
    "p": null,
    "q": null,
    "qi": null,
    "t": null,
    "x": null,
    "y": null
  },
  "managed": null,
  "releasePolicy": null,
  "tags": {}
}

Generación del fichero con los valores encriptados

Una vez tenemos generada la clave, ya si que podemos trabajar con Mozilla SOPS. Para ello podéis seguir el siguiente enlace para instalaros el software.

Una vez está instalado, debemos tener el fichero a encriptar en nuestro local (recordemos que este fichero nunca será subido al repo).

SOPS soporta, entre otros, JSON y YAML y sólo encripta los "values" del fichero, no las claves. Por tanto, con un fichero original tal que así:

db-password:
    value: fd4fg!sxAz46
    description: Contraseña de BD
kafka-password:
    value: Zdj!34fzAg8_
    description: Contraseña de Broker de Kafka

Vamos a aplicar los siguientes pasos...:

export AZURE_TENANT_ID=<TENANT_ID>
export AZURE_CLIENT_ID=<CLIENT_ID>
export AZURE_CLIENT_SECRET=<CLIENT_SECRET>

sops --encrypt --azure-kv <key.kid> raw-file.yaml > encrypted-file.yaml

Siendo las variables AZURE_* las necesarias para que SOPS pueda acceder a la clave a través del service principal y generar el fichero encriptado.Además KEY.KID es el ID de la clave previamente obtenida, en este caso: https://<VAULT_URL>/keys/sops-key/e5b9c8a1f0654cdbbabea8233b4692a1. Al ejecutar el comando se nos crea un nuevo fichero encrypted-file.yamlcon el siguiente contenido:

db-password:
    value: ENC[AES256_GCM,data:oPuPPFRT89YVJtDo,iv:m8EyD6MFirlpX5p4S012ovROxw/SGqsLlGv9m2vzmww=,tag:r/SHCbWIQ7HRDJM+swQlXQ==,type:str]
    description: ENC[AES256_GCM,data:6tVAwkY1ymrsU6H7z8fePls=,iv:KbOWDLZ2JUlJoDI18KAa3HFmjmljPiJFNDnPKWCB9Nc=,tag:MtvJ7w81jPr6a74MVCc1qg==,type:str]
kafka-password:
    value: ENC[AES256_GCM,data:QHzFPOyvTXQR7wfm,iv:jz6oSxJTP3HyIe6UnqOkHzJ/SZzpWTrZMgsET/WQ1Po=,tag:3fXTbMVT3LqPG/eOF8xhzg==,type:str]
    description: ENC[AES256_GCM,data:ZIgk4d7bluX+wqDtEfQPwEwIfFxa6kgtKxWzCWzB,iv:wei3MmdRzejxZ7QVRg4mNpCWmSVuaXoo1g6wtBccNTE=,tag:CC+/6xxuFI80oudyQYCnlg==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv:
        - vault_url: <VAULT_URL>
          name: sops-key
          version: e5b9c8a1f0654cdbbabea8233b4692a1
          created_at: "2023-05-08T08:20:04Z"
          enc: DSfTMiYOetq2fmX07_5SjqrwbGpkBh9l42PhpH5wraX1ZxB25GIk3O7nnMpkjxyrmwsaoJDfPvY8M_FPSRUB_h3fdNGdZ26K5NM7FPpe7w4zVrf-2h5VCKqRLiFL5tQU_dEzM1XMMaUqQTYh0cqo_b_6ygJCSfzYBdegqp8hR7LWtYEE5t0P7QdyjP5uiRyA8Nh0cBdgv9aAqy1TxbuQRqp-6_34__myL6PNfOVsH10R2x9HmZrTJ9l7MaWe6yX5uQTt8qh_b22YoAr5XkbMrw5TLGI_F6kG-g7S0UhKV_4nnpSMFzj_fgvlzfqUMyEybspbkV7h-tF0lsaZV8rnrw
    hc_vault: []
    age: []
    lastmodified: "2023-05-08T08:20:05Z"
    mac: ENC[AES256_GCM,data:2EAhmoBPb9zPkOry6DEgcmix4o/6OhB3wXLlZlz5V8GdzkfnqIsZ4f05jRIKS7StSBGfzfHN0Svvod5rGp6SS90lREDe105nACoIbaCInEs5V+7slWjgG24jiA8f+AefkdFe5NAnk20E7H+/PgFrE4nSR9m/ZxB+JPhLyl29XSk=,iv:yY339DTz2v0UGNkIDrv5UT/xd1IPAMT2ibDtAZLmSts=,tag:k3vDhCVKBhj0OvC0DYfsmg==,type:str]
    pgp: []
    unencrypted_suffix: _unencrypted
    version: 3.7.3

En dicho fichero podemos ver lo siguiente:

  • Tan solo los valores asociados a los atributos de db-password y kafka-password se han encriptado, por lo que seguimos teniendo acceso a las claves (db-password.value, description...)

  • Se ha añadido un nuevo contenido al fichero bajo el tag sops. Donde se le indica a SOPS toda la información relativa a la clave que debe usar para encriptar y desencriptar.

Este fichero si puede ser subido a un repositorio de Git, ya que está encriptado y el acceso a la clave de SOPS lo marcan los access policies definidas, por lo que sólo personas o aplicaciones con permisos adecuados podrían desencriptar el contenido.

Integración de SOPS y Terraform

Una vez tenemos la clave generada y el fichero encriptado, nos queda integrar ambas piezas para, mediante terraform, tener la capacidad de crear los secretos en el Key Vault de Azure.

Para ello, lo primero que vamos a definir es los secretos que queremos generar. En este caso he optado por crear un fichero locals.tf donde definido los secretos que están contenidos en el fichero encrypted-file.yml

locals {
  secrets = [
    "db-password",
    "kafka-password"
  ]
 
}

Ahora basta con crear un nuevo recurso en terraform que recorriendo este listado de nombres me cree los secretos obteniendo el valor del fichero SOPS. Pero antes, debemos decirle a terraform que debe saber trabajar con SOPS. Por tanto debemos añadirle un provider específico, en el fichero providers.tf añadimos lo siguiente:

terraform {
  
  backend "azurerm" {
    resource_group_name  = "<RESOURCE_GROUP_NAME>"
    storage_account_name = "<STORAGE_ACCOUNT_NAME>"
    container_name       = "<CONTAINER_NAME>"
    key                  = "sops.terraform.tfstate"
  }

  required_providers {
    sops = {
      source = "carlpett/sops"
      version = "0.7.2"
    }
  }
}

provider "azurerm" {
  features {}
}

provider "sops" {
}

Dicho provider hace un wrapper sobre SOPS para poder trabajar directamente con el CLI mediante terraform.

A nivel de terraform, basta con definir el recurso asociado al fichero SOPS y el recurso iterativo para crear los secretos, esto es:

data "sops_file" "file" {
  source_file = "../sops/src/encrypted-file.yaml"
}

resource "azurerm_key_vault_secret" "key_vault_secret" {
    for_each                = toset(local.secrets)
    name                    = "${each.key}-secret"
    value                   = data.sops_file.file.data["${each.key}.value"]
    key_vault_id            = data.azurerm_key_vault.keyvault.id
}

Mención especial al datasource "sops_file" y al for_each definido sobre locals.secret para crear los secretos (recurso de tipo "azurerm_key_vault_secret"). Mediante esto se crearán 2 secretos en Azure con los nombres db-password-secret y kafka-password-secret. Ahora sí volvemos a ejecutar los siguientes comandos de terraform:

terraform init
terraform validate
terraform plan -out thePlan.plan -var-file="inputs.tfvars"

En este caso aparecerá en los logs la siguiente información:

Terraform will perform the following actions:

  # azurerm_key_vault_secret.key_vault_secret["db-password"] will be created
  + resource "azurerm_key_vault_secret" "key_vault_secret" {
      + id                      = (known after apply)
      + key_vault_id            = "<VAULT_ID>"
      + name                    = "db-password-secret"
      + resource_id             = (known after apply)
      + resource_versionless_id = (known after apply)
      + value                   = (sensitive value)
      + version                 = (known after apply)
      + versionless_id          = (known after apply)
    }

  # azurerm_key_vault_secret.key_vault_secret["kafka-password"] will be created
  + resource "azurerm_key_vault_secret" "key_vault_secret" {
      + id                      = (known after apply)
      + key_vault_id            = "<VAULT_ID>"
      + name                    = "kafka-password-secret"
      + resource_id             = (known after apply)
      + resource_versionless_id = (known after apply)
      + value                   = (sensitive value)
      + version                 = (known after apply)
      + versionless_id          = (known after apply)
    }

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

Tras ello, aplicamos:

terraform apply "thePlan.plan"

Y comprobamos que todo se ha creado correctamente, por ejemplo con el AZURE CLI

manu@AT-5CD2144YCB:/mnt/c/Dev/github/mgvinuesa/azure-secrets-terraform-and-sops/terraform$ az keyvault secret show --name db-password-secret --vault-name <VAULT_NAME>
{
  "attributes": {
    "created": "2023-05-08T08:37:47+00:00",
    "enabled": true,
    "expires": null,
    "notBefore": null,
    "recoveryLevel": "Recoverable",
    "updated": "2023-05-08T08:37:47+00:00"
  },
  "contentType": "",
  "id": "<SECRET_ID>",
  "kid": null,
  "managed": null,
  "name": "db-password-secret",
  "tags": {},
  "value": "fd4fg!sxAz46"
}

En el campo value podemos ver el valor original del secreto. fd4fg!sxAz46

Bonus Track - Generar nuestras propias contraseñas

En la anterior sección hemos visto como gestionar la generación de secretos mediante SOPS y Terraform para un entorno cloud como Azure. Siempre que podamos gestionar el valor de los secretos a través de terraform, evitaremos el uso de herramientas de terceros como SOPS.

Para ello, terraform provee de un recurso especial para generar contraseñas, de esta manera evitaríamos debe tener almacenados los secretos para ser usados.

Para crear un secreto con dicho recurso, bastaría con usar el recurso random_passwordy enlazarlo al nuevo secreto:

resource "random_password" "password" {
  length           = 16
  special          = true
  override_special = "!#$%&*()-_=+[]{}<>:?"
}

resource "azurerm_key_vault_secret" "key_vault_secret_with_password" {
    name                    = "random-password-secret"
    value                   = random_password.password.result
    key_vault_id            = data.azurerm_key_vault.keyvault.id
}

Y volver a ejecutar los comandos de terraform

terraform init
terraform validate
terraform plan -out thePlan.plan -var-file="inputs.tfvars"

Ahora el plan definirá añadir dos nuevos objetos, el password generado y el secreto asociado:

  + resource "azurerm_key_vault_secret" "key_vault_secret_with_password" {
      + id                      = (known after apply)
      + key_vault_id            = "<KEY_VAULT_ID>"
      + name                    = "random-password-secret"
      + resource_id             = (known after apply)
      + resource_versionless_id = (known after apply)
      + value                   = (sensitive value)
      + version                 = (known after apply)
      + versionless_id          = (known after apply)
    }

  # random_password.password will be created
  + resource "random_password" "password" {
      + bcrypt_hash      = (sensitive value)
      + id               = (known after apply)
      + length           = 16
      + lower            = true
      + min_lower        = 0
      + min_numeric      = 0
      + min_special      = 0
      + min_upper        = 0
      + number           = true
      + numeric          = true
      + override_special = "!#$%&*()-_=+[]{}<>:?"
      + result           = (sensitive value)
      + special          = true
      + upper            = true
    }

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

Aplicamos el plan:

terraform apply "thePlan.plan"

Para ver el contenido del password, podemos como antes consultar directamente el vault, o en terraform almacenarlo como contenido sensible, para ello añadimos un objeto de tipo "output" a nuestro fichero de terraform:

output "random_password" {
  value = "${random_password.password.result}"
  sensitive = true
}

Al aplicar el plan tendremos almacenado dicho contenido en el estado de terraform. Por lo que podemos consultarlo a través del siguiente comando:

PS C:\Dev\github\mgvinuesa\azure-secrets-terraform-and-sops\terraform> terraform output random_password   

"w:2]kD7dA!$QirjC"

IMPORTANTE, para conocer en profundidad como gestionar el contenido sensible en terraform, es muy recomendable leerse esta documentación https://developer.hashicorp.com/terraform/language/state/sensitive-data

Conclusiones

En este artículo se ha presentado SOPS como una herramienta muy util para gestionar secretos en entornos donde tengamos infraestructura como código, con SOPS evitamos tener que exponer el contenido sensible en nuestro repositorio de código y nos facilita la integración con los principales proveedores de infraestructura cloud.

Además, junto con terraform se convierte en uno de los principales stacks de IAC del mercado, permitiendo gestionar no solo los secretos sino toda la infraestructura necesaria en nuestro proveedor cloud.

Todo el contenido de este post lo podéis encontrar en el siguiente repositorio de GitHub.