Работая с секретами, хочется получить две возможности: просто и централизованно управлять секретами в кластере и в то же время вынести их за пределы кластера в целях безопасности. В этой статье мы подробно рассмотрим работу External Secrets Operator в связке с Yandex Lockbox, AWS Secrets Manager, Vault by HashiCorp, а также решение на базе Open Source-утилиты от «Фланта» shell-operator.

Что такое External Secrets Operator

External Secrets Operator расширяет возможности Kubernetes с помощью Custom Resources, которые определяют местоположение секретов и способы их синхронизации. Контроллер запрашивает секреты из внешнего API и создает секреты Kubernetes. Если секрет во внешнем API изменяется, контроллер производит мониторинг состояния кластера и обновляет секреты.

Начнем с установки External Secrets Operator. Для этого воспользуемся самым простым методом — Helm

helm repo add external-secrets https://charts.external-secrets.io 
helm install external-secrets \
     external-secrets/external-secrets \
     -n external-secrets \
     --create-namespace
  • external-secrets — это имя релиза, под которым будет установлен External Secrets. Имя релиза может быть любым, главное, чтобы оно оставалось уникальным в рамках одного кластера Kubernetes.

  • external-secrets/external-secrets — это имя чарта, который мы хотим установить из репозитория External Secrets.

  • -n external-secrets — это флаг, который указывает, что мы хотим создать новое пространство имен (namespace) с именем external-secrets для установки релиза.

  • --create-namespace — это флаг, который указывает на необходимость создания нового пространства имен (namespace), если оно ещё не существует. Если пространство имен уже существует, этот флаг можно опустить.

Проверяем, что все поды запустились и оператор работает:

kubectl -n external-secrets get po

NAME                                                READY   STATUS    RESTARTS   AGE
external-secrets-5b9599dbd4-czhnh                   1/1     Running   0          49s
external-secrets-cert-controller-6d45db4b4d-zxmmd   1/1     Running   0          49s
external-secrets-webhook-5b9c855467-cxbcs           1/1     Running   0          49s

Теперь все готово для подключения наших секретов.

Работа с Yandex Lockbox

Настройка связки Yandex Lockbox и External Secrets Operator отлично описана в официальной инструкции от Яндекса. Но мы все равно кратко пробежимся по ней.

Для начала работы нам понадобится создать сервисный аккаунт в Яндекс Облаке для Lockbox.

В нем необходимо прописать две роли:

  • kms.keys.encrypterDecrypter — для расшифровки секретов;

  • lockbox.viewer — для доступа к хранилищу.

После этого открываем сервисный аккаунт и создаем авторизованный ключ:

Сохраняем файл с ключами authorized-key.json — он еще понадобится нам для настройки — и запоминаем идентификатор секрета (в нашем примере e6qqssbs94tjpvdb78p9).

Чтобы создать наш первый секрет, необходимо зайти в Lockbox и нажать на кнопку «Создать секрет».

Тут же создадим ключ KMS для шифрования секретов:

И заполним все поля секрета:

Теперь добавим права для нашего сервисного аккаунта к созданному на прошлых шагах секрету. Для этого перейдем на вкладку «Права доступа» и нажмем кнопку «Назначить роли»:

И так же добавляем роль для kms ключа

Добавим в кластер ключ от сервисного аккаунта. Для этого необходимо выполнить следующую команду:

kubectl --namespace secrets-test create secret generic yc-auth --from-file=authorized-key=authorized_key.json

Далее нам нужно будет создать либо SecretStore либо ClusterSecretStore. Их основное отличие в том, что ClusterSecretStore будет доступен из любого пространства имен, а SecretStore — нет (пространство имен в этом случае будет задаваться на этапе создания сущности SecretStore — в примере ниже за это отвечает строка namespace: secrets-test). Создадим манифест secret-store-yc.yaml для SecretStore:

---
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: secret-store-yc
  namespace: secrets-test
spec:
  provider:
    yandexlockbox:
      auth:
        authorizedKeySecretRef:
          name: yc-auth
          key: authorized-key

Теперь задеплоим его в наш кластер:

kubectl apply -f secret-store-yc.yaml

После этого создадим ExternalSecret с помощью файла external-secret-yc.yaml

---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: external-secret
  namespace: secrets-test
spec:
  refreshInterval: 10m
  secretStoreRef:
    name: secret-store-yc
    kind: SecretStore
  target:
    name: yc-secret
  data:
  - secretKey: yc-key
    remoteRef:
      key: e6qqssbs94tjpvdb78p9
      property: SOME_KEY

Для более тонкой настройки или использования сразу нескольких ключей рекомендую ознакомиться с документацией по ExternalSecret.

Деплоим ExternalSecret в кластер с помощью соответствующего YAML-файла:

kubectl apply -f external-secret-yc.yaml

Проверим, работает ли наше решение: если мы все сделали правильно и у нас есть все необходимые права, в системе появится ExternalSecret в статусе SecretSynced:

kubectl -n secrets-test get externalsecrets.external-secrets.io
NAME              STORE             REFRESH INTERVAL   STATUS         READY
external-secret   secret-store-yc   10m                SecretSynced   True

Кроме того, появится секрет yc-secret. Вот его значение:

kubectl -n secrets-test get secrets yc-secret -ojson | jq -r '.data | map_values(@base64d)'

{
  "yc-key": "SOME_VALUE"
}

Работа с AWS Secret Manager

У AWS тоже все отлично с документацией. Приступим к настройке.

Сначала создадим пользователя для работы с секретами — тут нам будет необходимо зайти в IAM → Users → Add users.

Задаем имя

Добавляем политику. Можно воспользоваться готовой от AWS — SecretsManagerReadWrite, а можно создать свою. Мы пойдем по второму пути и создадим свою:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "secretsmanager:GetResourcePolicy",
                "secretsmanager:GetSecretValue",
                "secretsmanager:DescribeSecret",
                "secretsmanager:ListSecretVersionIds"
            ],
            "Resource": "arn:aws:secretsmanager:*:111111111111:secret:*"
        }
    ]
}

Прикрепляем политику к новому пользователю и создаем этого самого пользователя, а после этого создадим ключ доступа:

Выбираем Other, добавляем тег и сохраняем свои данные для авторизации:

Теперь идем в AWS Secrets Manager и создаем свой первый секрет:

Теперь подготовим манифесты для подключения в кластер:

---
apiVersion: v1
kind: Secret
metadata:
  name: aws-creds
  namespace: secrets-test
type: Opaque
stringData:
  accessKey: "AKIA46CKSTMN32RJDFXY"
  secretAccessKey: "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"

---
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: secret-store-aws
  namespace: secrets-test
spec:
  provider:
    aws:
      auth:
        secretRef:
          accessKeyIDSecretRef:
            key: accessKey
            name: aws-creds
          secretAccessKeySecretRef:
            key: secretAccessKey
            name: aws-creds
      region: eu-central-1
      service: SecretsManager
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: external-secret-aws
  namespace: secrets-test
spec:
  refreshInterval: 10m
  secretStoreRef:
    name: secret-store-aws
    kind: SecretStore
  target:
    name: aws-secret
    creationPolicy: Owner
  data:
  - secretKey: aws-key
    remoteRef:
      key: aws-test-secret
      property: SOME_AWS_KEY

В итоге получился секрет aws-secret с нашим значением:

kubectl -n secrets-test get secrets aws-secret -ojson | jq '.data | map_values(@base64d)'
{
  "aws-key": "SOME_AWS_VALUE"
}

Работа с HashiCorp Cloud Platform (HCP) Vault Secrets

У этой платформы тоже есть толковая документация.

werf helm install vault hashicorp/vault --namespace secrets-hcp-vault

После установки необходимо инициализировать наш vault. Обязательно сохраните ключи для последующей распечатки хранилища.

kubectl -n secrets-hcp-vault exec -it vault-0 -- sh
/ $ vault operator init

Unseal Key 1: AOFvS1ZVHpLwdKOIX/PFX1YFH9w213ayxsvKIGapEMam
Unseal Key 2: 3Z32U3CruHi83LJnrfz8ptd3tJRTe5DPghQxYG34JtDS
Unseal Key 3: 1re6jWEJuemQXGvwByQzYR4TH02nfcDcf3glK8m/gAI4
Unseal Key 4: AEV3pLV3ddFndToUu2X40fduM1B2167zmeFj/1EmMqBo
Unseal Key 5: XRK2XDxo9NQzN8EVXcLpNBppkW12v+Dm9Hzqq+CBlNp8
Initial Root Token: hvs.Y87NbYmHLvvaZVH0oIsPskZW

C помощью трех ключей распечатки запускаем хранилище и заходим в него под root:

vault operator unseal <key>

vault login <root_key>

Теперь мы можем добавить в хранилище наш тестовый секрет:

vault secrets enable --path=secret kv

vault kv put secret/test-hcpv hcpv-key=HCPV_VALUE

vault list secret

vault kv get secret/test-hcpv

Создадим политику для доступа к нашему секрету и токен для дальнейшей настройки:

vault policy write my-vault-policy - <<EOF
path "secret/*" {
capabilities = ["create", "read", "update", "patch", "delete", "list"]
}
EOF
vault token create -policy=my-vault-policy
Key                  Value
---                  -----
token                hvs.CAESIKVdmmqykGVg6yiqDMeB8oC-lAUI_veOWxBKe2AoY490Gh4KHGh2cy40aFpVUldkVlU5SmhuSFQ1TWo2RGt3OFI
token_accessor       QSvnCPet91BAUn0ZZpmBeiGe
token_duration       768h
token_renewable      true
token_policies       ["default" "my-vault-policy"]
identity_policies    []
policies             ["default" "my-vault-policy"]

После этого, аналогично предыдущим примерам, подготовим манифесты для подключения в кластер:

---
apiVersion: v1
kind: Secret
metadata:
  name: hcpv-creds
  namespace: secrets-test
type: Opaque
stringData:
  hcpvToken: "hvs.CAESIKVdmmqykGVg6yiqDMeB8oC-lAUI_veOWxBKe2AoY490Gh4KHGh2cy40aFpVUldkVlU5SmhuSFQ1TWo2RGt3OFI"

---
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: secret-store-hcpv
  namespace: secrets-test
spec:
  provider:
    vault:
      server: "http://vault.secrets-hcp-vault.svc.cluster.local:8200"
      path: "secret"
      version: "v1"
      auth:
        tokenSecretRef:
          name: "hcpv-creds"
          key: "hcpvToken"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: external-secret-hcpv
  namespace: secrets-test
spec:
  refreshInterval: 10m
  secretStoreRef:
    name: secret-store-hcpv
    kind: SecretStore
  target:
    name: hcpv-secret
  data:
  - secretKey: hcpv-key
    remoteRef:
      key: secret/test-hcpv
      property: hcpv-key

И, наконец, проверяем наш секрет hcpv-secret с заданным значением:

kubectl -n secrets-test get secrets hcpv-secret -ojson | jq '.data | map_values(@base64d)'
{
  "hcpv-secret-key": "HCPV_VALUE"
}

Работа с shell-operator

Shell-operator — это наша Open Source-утилита для более простого создания Kubernetes-операторов.

А что делать, если мы не хотим использовать секреты в качестве объектов K8s? Для этого мы создали образ shell-operator с Yandex-cli, в котором были хуки, работающие либо по расписанию, либо по триггерам. С помощью оператора можно реализовать и создать любую логику.

Мы в одном из проектов «Фланта» ходим в Яндекс Облако с помощью сервисного аккаунта и запрашиваем JWT Token, который меняем раз в шесть часов для безопасности. Кроме того, сам shell-operator подписан на события: как только в кластере появляется секрет с аннотацией lockbox=yes оператор создает новый секрет или заменяет его, а после удаляет ненужные секреты. Ниже приведен пример хука:

hook::trigger() {
  # Copy secrets to the other namespaces.
  for secret in $(kubectl -n ${WATCHED_NAMESPACE} get secret -l lockbox=yes --no-headers -o custom-columns=":metadata.name");
    do
      ELAPSED=`kubectl::get_expire_seconds ${WATCHED_NAMESPACE} ${secret}-iam`
      if (( $ELAPSED > 21600)); then
        echo "${secret}-iam doesn't require the update and have ttl - ${ELAPSED} seconds"
        exit 0
      fi
      kubectl -n ${WATCHED_NAMESPACE} get secret $secret -o json| jq -r ".data.sa" | base64 -d > /tmp/sa.json
      ${YC_BIN} config set service-account-key /tmp/sa.json
      rm /tmp/sa.json
      PAYLOAD=$(${YC_BIN} iam create-token --format json)
      TOKEN=$(echo $PAYLOAD | jq -r .iam_token)
      EXPIRATION=$(echo $PAYLOAD | jq -r .expires_at)
      EXPIRATION_TIMESTAMP=$(date -d ${EXPIRATION} +%s)
      # copy secret with a necessary data
      common::generate_secret $secret ${WATCHED_NAMESPACE} ${EXPIRATION} ${TOKEN} ${EXPIRATION_TIMESTAMP}| kubectl::replace_or_create
  done

  # Delete secrets with the 'secret-copier: yes' label in namespaces except 'default', which are not exist in the 'default' namespace.
  kubectl -n ${WATCHED_NAMESPACE} get secret -o json | \
    jq -r '([.items[] | select(.metadata.labels."lockbox" == "yes").metadata.name]) as $secrets |
             .items[] | select(.metadata.labels."lockbox.managed" == "yes" and ([.metadata.labels."lockbox.parent"] | inside($secrets) | not)) |
             "\(.metadata.namespace) secret \(.metadata.name)"' | \
    while read -r secret
    do
      kubectl delete -n $secret
    done
}

Что здесь важно понимать: 

  • Функция common::generate_secret() генерирует JSON для создания секрета Kubernetes.

  • Функция kubectl::replace_or_create() выполняет замещение или создание секрета.

  • Функция kubectl::get_expire_seconds() получает количество оставшихся секунд до истечения срока действия секрета.

function common::generate_secret(){
  echo "{
  \"apiVersion\": \"v1\",
  \"kind\": \"Secret\",
  \"metadata\": {
    \"name\": \"$1-iam\",
    \"namespace\": \"$2\",
    \"annotations\": {
      \"lockbox.expires\": \"$3\",
      \"lockbox.expires_timestamp\": \"$5\"
    },
    \"labels\": {
      \"lockbox.parent\": \"$1\",
      \"lockbox.managed\": \"yes\"
    }
  },
  \"type\": \"Opaque\",
  \"stringData\": {
    \"token\": \"$4\"
  }
}"
}
function kubectl::replace_or_create() {
  object=$(cat)

  if ! kubectl get -f - <<< "$object" >/dev/null 2>/dev/null; then
    kubectl create -f - <<< "$object" >/dev/null
  else
    kubectl replace --force -f - <<< "$object" >/dev/null
  fi
}
function kubectl::get_expire_seconds() {
  NAMESPACE=$1
  SECRET=$2
  if PAYLOAD=$(kubectl -n ${NAMESPACE} get secret ${SECRET} -o custom-columns=":metadata.name"); then
    EXPIRATION_TIME=$(kubectl -n ${NAMESPACE} get secret ${SECRET} -o json | jq -r '.metadata.annotations."lockbox.expires_timestamp"' || echo "0" )
  else
    EXPIRATION_TIME="0"
  fi
  NOW=$(date +%s)
  echo $((${EXPIRATION_TIME} - ${NOW}))
}

Заключение

Как мы выяснили, External Secrets Operator отлично решает задачу безопасного хранения секретов вне кластера. А вот выбор хранилища в первую очередь будет зависеть от облака, в котором располагается инфраструктура, и от необходимой функциональности.

AWS за счет гибкого механизма IAM позволяет ограничивать ресурсы, доступные для текущего сервисного аккаунта. Если же нужен полный контроль, то можно использовать HCP Vault. Правда, его придется достаточно глубоко изучить, прежде чем запускать в production.

P.S.

Читайте также в нашем блоге: