Многие DevOps-инженеры пользуются HashiCorp Vault для хранения секретов или управления ими.

Помимо того, что все данные в хранилище надёжно зашифрованы, инструмент позволяет гранулярно выдать доступ на какие-то операции. Например, с помощью одной учётной записи секрет можно создать, но нельзя прочитать или изменить. А с помощью другой можно только прочитать. Этим все повсеместно пользуются — инженеры и разработчики читают или создают секреты, а какой-то администратор выдаёт к этим секретам доступы.

И тут есть подвох — администратор может прочитать любые секреты, которые может прочитать хоть кто-то ещё.

Есть ли в Vault секреты, которые не может прочитать администратор?

На самом деле в HashiCorp Vault существуют секреты, которые администратор прочитать не может, так называемые «неизвлекаемые». Хотя они хранятся внутри Vault, нет никакого метода API, позволяющего такие секреты прочитать. Пример — закрытые ключи PKI. С помощью этих ключей Vault подписывает сертификаты на основании заданных ролей и политик, но не предоставляет возможности извлечь такой ключ наружу, чтобы кто-то с его помощью не подписал какой-то сертификат в обход вышеупомянутых ролей и политик.

Другой тип секретов, которые не может прочитать администратор, — это секреты cubbyhole. Они явно привязаны к конкретному токену. Минус в том, что они уничтожаются вместе с токеном, а потому не подходят для долгосрочного хранения или использования группой сервисов.

Почему так получается? Раз администратор может выдать вам доступ к какому-то ресурсу внутри Vault, то он может выдать такой же доступ и себе. Да, это не останется незамеченным службой ИБ, но только при условии, что у вас такая есть, она внимательно следит за всеми запросами в API Vault, и её сложно обмануть (например, временным выключением аудит-логов).

Неужели всё так плохо?

На самом деле нет. В HashiCorp Vault заложено решение, которое позволяет сделать конфигурацию доступа неизменяемой. Точнее — не изменяемой одним человеком.

Разделяем ответственность

Вариант 1. Безопасно и неудобно

Если вы пользовались Vault, то наверняка помните, как происходит его инициализация: генерируются root-token для доступа к API и несколько частей ключа шифрования, разбитые по алгоритму Shamir. Чтобы запустить Vault, необходимо ввести все части ключа. По-хорошему, они должны быть у разных людей. Поэтому для старта сервиса нужен кворум из нескольких владельцев ключа.

Как зашифровать части ключей

При генерации частей ключа каждую из них можно зашифровать разными публичными ключами шифрования: в этом случае ключи никогда не отображаются вместе в открытом виде. Таким образом, каждую часть ключа сможет прочитать только владелец закрытой части PGP-ключа.

Сразу после инициализации все владельцы ключей должны собраться вместе и распечатать Vault. После этого они могут сконфигурировать политики и доступы в нём, используя root-token, и, когда настройка завершится, root-token отозвать. Это явным образом указано в рекомендациях к эксплуатации Vault. 

Всё. После этих действий будет невозможно произвести никакие изменения доступа, если они явно не разрешены политиками начальной конфигурации. Если в политиках разрешён только доступ к секретам kv-v2 по пути secrets, то ни создать новый kv-v2 по другому пути, ни получить доступ к нему будет нельзя. Внести изменения напрямую в базу данных Vault тоже не получится: она не только зашифрована, но ещё и каждая запись в базе криптографически подписана и верифицируется при чтении.

Единственный способ что-то поменять — сгенерировать новый root-token с помощью набора Shamir-ключей. А для этого нужно снова собрать кворум из необходимого числа людей, владеющих этими ключами. То есть такие изменения точно не окажутся незамеченными. И, конечно же, после работ по изменению конфигурации нужно снова отозвать root-token.

Безопасно? Супербезопасно! Удобно? Безусловно, нет!

Неудобно потому, что в реальности редко существуют статические конфигурации. В реальных кейсах часто приходится что-то менять, и каждый раз проводить для этого процедуру генерации root-token с помощью Shamir-ключей очень накладно. По-хорошему, сами Shamir-ключи надо бы после этого тоже перегенерировать, так как они потенциально были «засвечены» при вводе.

Вариант 2. Удобно и небезопасно

Иногда для решения обозначенной выше проблемы пользуются таким методом. Конфигурацию доступов к Vault хранят в Git-репозитории, и при слиянии в main или создании тега процесс CI/CD помещает эту конфигурацию в Vault. Казалось бы, всё отлично, запросы на слияние могут проходить ревью, а слияние и запуск CI произойдут, только если наберётся нужное количество апрувов.

Для людей, которые делают коммиты (изменения) в Git, всё выглядит действительно так. Безопасно. Но это только видимость:

  • Администратор GitLab/GitHub способен поменять список людей, которые могут выполнять слияние.

  • Владелец репозитория может сделать force push вообще без ревью.

  • Если процесс CI получает доступ к API Vault с правами на изменение конфигурации, значит, в GitLab/Jenkins/и т. д. содержатся какие-то учётные данные, позволяющие получить такой токен доступа, и потенциальному злоумышленнику нужно «всего лишь» их там найти.

  • Можно создать другой аналогичный репозиторий, но запустить в нём тот же CI.

Во всех случаях есть какой-то один человек, у которого потенциально есть права изменять политики и конфигурацию Vault. Ну или пусть не «один человек», а «один сервис», который нужно взломать, чтобы получить доступ в хранилище секретов.

Другая проблема Git — доверие к коммитам. Хотя коммит и можно подписать PGP, у одного коммита может быть только одна подпись. Если нужна вторая подпись, то это будет другой коммит. В Git нет нативного способа нескольким людям подписать один и тот же коммит (маркер состояние кода) и таким образом собрать кворум подписей.

Задача — сделать безопасно и удобно

Git как точка правды

Идеальное решение с конфигурацией HashiCorp Vault было бы примерно таким:

  • Конфигурация хранится в Git, который позволяет отследить цепочку изменений и удобно работать с конфигурацией команде из нескольких человек.

  • Хеш коммита гарантированно определяет конкретное состояние кода. Это почти блокчейн, только коммиты в нём можно изменять. Но при этом меняются и хеши коммитов, нужно просто следить за хешем.

  • «Хороший» коммит подписывается несколькими PGP-ключами, приватные части которых находятся у доверенных лиц. 

  • В Vault на этапе инициализации, когда root-token ещё не отозван, помещают публичные части этих PGP-ключей.

  • Vault сам проверяет состояние Git-репозитория и приводит свою конфигурацию к состоянию указанного коммита, если находит новый коммит, подписанный нужным количеством ключей.

В этом случае:

  • Добавление коммита в проект не приводит к изменению конфигурации до тех пор, пока не будет нужного числа подписей.

  • Доступа к изменению конфигурации Vault нет ни у системы CI, ни у некоего «администратора». Ни у кого вне самого Vault.

  • Vault применяет конфигурацию только при наличии кворума валидных PGP-подписей.

Хотя Git и не поддерживает множественную подпись коммитов, проблема не является нерешаемой. Так как хеш коммита однозначно определяет состояние кода, достаточно подписать его несколькими PGP-ключами и поместить подписи в доступное для Vault место. Что самое приятное, это можно сделать прямо в Git, используя Git Notes. Git позволяет хранить заметки, привязанные к коммитам, причём заметки можно изменять без необходимости изменения самих коммитов. 

Чтобы не делать подпись и добавление Note вручную, существуют довольно простые надстройки для консольного Git, которые упрощают подпись-апрув до команды git signatures add.

Вот примеры:

Какие минусы есть у Git с подписями, учитывая, что мы хотим сделать не изменяемую без кворума конфигурацию?

Коммиты можно откатить.

Если вы сделали и подписали коммит А, и он применился, потом сделали и подписали коммит В, и он тоже применился, после чего форспушнули в репозиторий, и последним подписанным коммитом снова стал А, то он снова применится. Проблема ли это? Сложно сказать. Если ваш Vault восстановили из бэкапа, это проблема?

Этот момент можно обойти, не применяя коммиты старше, чем уже применённые. Но в таком случае нужно внимательно проверять дату коммита при подписи кворумом, чтобы не подписать коммит с датой 9999 год.

trdl

Подобный подход к валидации Git-коммитов кворумом из подписей используется в trdl — нашем решении для безопасной доставки обновлений, о котором мы писали ранее на Хабре. 

Однако там Vault рассматривается как доверенная зона, и некий «администратор Vault» управляет списком проектов, количеством необходимых подписей и списком ключей для подписи. Наша же задача — с помощью кворума управлять непосредственно конфигурацией самого Vault.

Приступаем к решению — пока в теории

Итак, у нас есть возможность множественного подписания коммитов в Git. Но как хранить в Git конфигурацию Vault?

В процессе конфигурации (и вообще любого взаимодействия) в Vault отправляется набор запросов в API. То есть можно было бы хранить конфигурацию в виде списка:

1. method + api-path + payload
2. method + api-path + payload
…

Вариант рабочий, но для человека плохо читаемый, а значит, и сложный в поддержке. И если для политик Vault это приемлемо, то для других ресурсов уже не очень удобно, ведь payload — это JSON-объект.

Во-вторых, не все запросы идемпотентные. Например, повторное создание секрета с тем же именем и данными приведёт к созданию новой версии секрета. А создание неймспейса с тем же именем приведёт к ошибке.

Третья проблема — удаление ресурсов. Их удаление из Git по-хорошему должно приводить и к удалению из Vault. А как узнать, что ресурс был удалён? Нужно как-то сравнивать текущий и предыдущий коммит. Или хранить state (состояние).

Ничего не напоминает? Точно! Это же Terraform:

  • Имеет хорошо документированный язык описания конфигураций HCL.

  • Имеет плагин для работы с Vault.

  • Хранит state и изменяет только те ресурсы, которые нужно изменять, при этом удаляет ресурсы из хранилища при их удалении из Git-репозитория.

В конце концов, 90 % тех, кто управляет конфигурацией Vault из Git, делают это с помощью Terraform. Просто пока они делают это небезопасно (если вдруг это вы, дочитайте статью до конца и начните делать безопасно).

Итак, план. Делаем плагин к Vault, в котором можно задать:

  • Git-репозиторий, содержащий конфигурацию;

  • перечень публичных ключей, с помощью которых мы будем проверять подпись коммитов;

  • количество необходимых подписей для применения конфигурации;

  • период проверки, доступы (логин/пароль) к приватному Git, CA Git-сервера при необходимости и прочие служебные настройки;

  • токен доступа нашего плагина в Vault.

На последнем стоит остановиться подробнее. Дело в том, что плагины в Vault изолированы и от ядра, и друг от друга. То есть из плагина нельзя «изменить какие угодно данные» в Vault, можно изменить только данные самого плагина. Поэтому управлять другими ресурсами Vault-плагин может только по API.

Чем это лучше управления из Jenkins-пайплайна? Очень просто:

  • Алгоритм плагина детерминированный и «вшит» в бинарный файл. При загрузке бинарного файла проверяется его SHA-сумма, записанная внутри Vault. Это значит, что нельзя изменить поведение плагина извне (например, заменив бинарь): Vault просто не запустит такой плагин. А вот скрипт в Jenkins заменить можно, ведь Vault ничего об этом не узнает.

  • Токен доступа к Vault находится в самом Vault. Причём в плагине нет метода API, чтобы его прочитать. Это тот самый «неизвлекаемый» секрет, который можно записать в Vault, но прочитать нельзя. Технически этот секрет хранится настолько же безопасно, насколько RootCA в плагине PKI. А если у вас работает Seal Wrap (enterprise-функция HashiCorp Vault), то токен доступа ещё и зашифрован дополнительно через KMS или HSM.

  • Сетевое взаимодействие не выходит за рамки localhost и даже в этом случае происходит по HTTPS с проверкой CA (можно использовать Unix socket, но вот тут уже, кажется, разницы никакой).

Для ещё большей безопасности можно сделать токен periodic, то есть обязать плагин периодически его продлевать. Если за отведённое время токен не будет продлён, он будет отозван. Например, если восстановить достаточно старую резервную копию, то в момент запуска Vault токен тут же отзовётся, и необходимо будет пройти всеми нелюбимую процедуру «давайте соберёмся все вместе и сгенерируем новый токен». Впрочем, делать токен периодическим необязательно.

Хорошо, плагин сконфигурирован. Что он делает?

  1. Периодически проверяет Git.

  2. Если находит новый коммит, то проверяет подписи. Кстати, если задать количество подписей 0, то любой коммит считается «валидным». Можно использовать такой режим для отладки, ну или если вам не нужна безопасность, а попросту требуется автоматическое применение конфигурации. 

  3. Если верных подписей достаточно, выполняет terraform apply.

  4. В случае успеха записывает номер применённого коммита в данные плагина (в Storage), чтобы более его не применять.

  5. Записывает состояние Terraform прямо в хранилку плагина. То есть не нужно никакое защищённое S3 или какой-то другой бэкенд, состояние хранится прямо в Vault.

  6. Если есть ошибки, то сохраняет их в хранилку, чтобы отдавать по запросу ручки status.

От слов — к делу

Предыдущие разделы статьи описывали проблему с управлением конфигурацией в HashiCorp Vault и план её решения. Эта проблема действительно серьёзная — супербезопасная архитектура Vault с кворумом для распечатки и первичной конфигурации постоянно разбивалась о реальность наличия единоличного привилегированного доступа к управлению конфигураций снаружи Vault в почти 100 % инсталляций.

Нет сил это терпеть. Поэтому берём VS Code, Golang, рисуем круг, а затем и сову. А в вашем случае — качаем и запускаем готовый плагин: https://github.com/trublast/vault-plugin-gitops

Поместите скачанный файл с именем gitops в папку plugin_directory, указанную в вашей конфигурации HashiCorp Vault.

Загрузите плагин в Vault:

SHA=$(sha256sum $PWD/gitops | awk '{print $1;}')
vault plugin register -command gitops -sha256 $SHA secret gitops
vault secrets enable -options=type=terraform gitops

Установите Terraform или OpenTofu. Если вы используете OpenTofu, переименуйте tofu в terraform или сконфигурируйте путь к файлу:

vault write gitops/configure/terraform \ 
terraform_binary="/usr/local/bin/tofu"

Добавьте репозиторий для мониторинга:

vault write gitops/configure/git_repository \
git_repo_url="https://gitlab.com/user/vault-gitops-configuration.git" \ 
git_poll_period=1m

Настройте взаимодействие плагина с Vault, задав vault_token и vault_address. Например, так:

vault write gitops/configure/vault vault_addr=http://127.0.0.1:8200 wrapping_token=$(vault token create -orphan -period=7d -wrap-ttl 1m -policy=root -display-name="gitops-plugin" -field=wrapping_token)

Если не указать vault_addr, то будет использоваться VAULT_ADDR из окружения Vault или http://127.0.0.1:8200 по умолчанию.

Теперь настроим проверку подписей коммитов.

Создайте ключи PGP:

gpg --quick-generate-key "key1 <key1@example.com>" rsa4096
gpg --quick-generate-key "key2 <key2@example.com>" rsa4096

Экспортируйте публичные части в файл:

gpg --armor --output key1.pgp --export key1
gpg --armor --output key2.pgp --export key2

И загрузите их в Vault:

vault write gitops/configure/trusted_pgp_public_key/key1 public_key=@key1.pgp
vault write gitops/configure/trusted_pgp_public_key/key2 public_key=@key2.pgp

Включите проверку подписей:

vault write gitops/configure/git_repository \
required_number_of_verified_signatures_on_commit=1

Допустим, у вас есть Git-репозиторий, в котором лежат файлы .tf (примеры можно посмотреть на GitHub). Пришло время подписывать коммиты.

Добавьте новый коммит. Например, создайте метод SSH из примеров:

resource "vault_mount" "ssh_backend" {
  type = "ssh"
  path = "ssh"
}

resource "vault_ssh_secret_backend_ca" "ssh_ca" {
  backend              = vault_mount.ssh_backend.path
  generate_signing_key = true
}

resource "vault_ssh_secret_backend_role" "ssh_access" {
  name                    = "ssh_access"
  backend                 = vault_mount.ssh_backend.path
  key_type                = "ca"
  allow_user_certificates = true
  allowed_extensions      = "permit-pty,permit-port-forwarding"
  default_extensions      = { permit-pty = "" }
  allowed_users           = "*"
  ttl                     = "1800"
}

Для удобной подписи через Git Notes потребуется установить git-signatures. Достаточно просто скопировать файл bin/git-signatures в ~/bin и выставить chmod a+x.

Посмотрите список ключей:

gpg --list-key

Укажите, какой ключ использовать для подписи:

git config user.signingKey 0C3AAAA10E30D5F3

Подпишите:

git signatures add

И проверьте:

git signatures show


 Public Key ID    | Status     | Trust     | Date                         | Signer Name
=====================================================================================================
 0C3AAAA10E30D5F3 | VALIDSIG   | ULTIMATE  | Пн 22 дек 2025 20:19:33 MSK  | key1 <key1@example.com>

Отправьте в апстрим:

git signatures push

И ожидайте, когда изменения появятся в Vault, например выполняя команду:

vault read gitops/status

И здесь самое время сказать «ура!», ведь подписанный коммит применился. А если в процессе были ошибки — посмотрите в статусе, что пошло не так.

Уходим в отрыв

Но самое интересно только начинается!

Управляем сами собой

Представьте, что вы можете сделать HashiCorp Vault полностью управляемым кворумом подписей Git. То есть при развёртывании Vault вы сразу конфигурируете плагин, а потом отзываете root-token. Пример такой конфигурации можно посмотреть в примере. Список ключей подписи управляется прямо из самого репозитория. Конечно, валидными эти изменения становятся, только если был набран кворум подписей.

Множественные плагины

Можно включить несколько плагинов для разных репозиториев или веток, и в каждый добавить токен с определённой политикой доступа. Тогда репозиторий будет управлять только частью ресурсов Vault. А если использовать возможности Namespaces (enterprise-функция), то полёт фантазии может быть безграничным.

Управление другими инфраструктурами

Поскольку мы используем Terraform (OpenTofu), то вообще-то не ограничены в управлении конфигурацией Vault. Например, мы можем управлять инфраструктурой в Yandex Cloud (или AWS, GCP), описанной с помощью Terraform, на основании кворума коммитов. А состояние будет храниться в безопасном хранилище Vault.

Ещё одно преимущество — ваши Terraform-манифесты смогут получать какие-то секреты для развёртывания из обычных секретов Vault (kv, pki, databases и других) и помещать tf-outputs обратно в секреты. При этом сами секреты в процессе не покидают Vault.

Пример — сохраняем секрет:

resource "aws_iam_user" "s3_user" {
  name = "s3-access-user"
}
# Создание IAM Access Key.
resource "aws_iam_access_key" "s3_access_key" {
  user = aws_iam_user.s3_user.name
}
# Запись секретов в Vault.
resource "vault_kv_secret_v2" "aws_credentials" {
  path = "aws/creds"
  data_json = jsonencode({
    access_key = aws_iam_access_key.s3_access_key.id
    secret_key = aws_iam_access_key.s3_access_key.secret
  })
}

Пример — читаем секрет:

# Чтение секретов из Vault.
data "vault_kv_secret_v2" "aws_creds" {
  mount = "kv"
  name = "aws/creds"
}

provider "aws" {
  region = "us-east-1"
  access_key = data.vault_kv_secret_v2.aws_creds.data["access_key"]
  secret_key = data.vault_kv_secret_v2.aws_creds.data["secret_key"]
}

# Создаём ресурсы AWS.
resource "aws_s3_bucket" "my_bucket" {
  bucket = "my-bucket-name"
  acl = "private"
}

Таким образом, Vault превращается в (почти) полноценную защищённую CI-систему, состояние которой не подвержено влиянию извне.

Ещё немного харденинга

Как могла бы выглядеть процедура инсталляции «идеального HashiCorp Vault»?

  1. Собираем 5 открытых PGP-ключей.

  2. Инициализируем Vault со схемой Shamir 3/5.

  3. Владельцы PGP вводят три ключа, и Vault запускается.

  4. В Vault добавляем плагин gitops, и в него загружаем 5 PGP-ключей (те же, что в первом пункте, или другие), выставляем проверку подписи — минимум 3 ключа.

  5. Настраиваем адрес Git-репозитория с конфигурацией Vault.

  6. Создаём обернутый (wrapping) токен и загружаем его в API плагина.

  7. Создаём политику read/list на все объекты, а также токен-аудитор с этой политикой с TTL 1 час без возможности продления. Этот токен передаём 5 участникам. В текущий момент он не является чувствительной информацией: в Vault ещё нет данных, которые можно прочитать по API, кроме базовой публичной конфигурации.

  8. Отзываем root-token.

  9. С этого момента мы считаем, что вносить изменения в конфигурацию невозможно. Но можно посмотреть её с помощью токена-аудитора.

  10. Каждый из участников с помощью токена-аудитора подключается в Vault и убеждается, что root-token действительно отозван, не создано никаких других токенов и нет никаких изменений в конфигурации, кроме описанных в плане. С этого момента каждый участник будет уверен, что «закладок» не осталось.

  11. Токен-аудитор автоматически отзывается по TTL.

  12. С этого момента управлять конфигурацией Vault можно только через Git с кворумом подписей. В том числе менять состав подписей и количество подписей, необходимых для работы.

А что, если… (Для тех, кто ещё не устал)

Во всей этой красивой истории с подписями, GitOps и Terraform меня беспокоил один момент — сам Terraform (или OpenTofu), а именно необходимость наличия бинарного файла terraform и бинарного файла плагина terraform-provider-vault. 

Контрольную сумму плагина Vault мы как-то проверяем, но похоже, что нужно проверять ещё и контрольную сумму Terraform, который, в свою очередь, должен проверять контрольную сумму terraform-provider-vault или любого другого плагина. Причём на версию плагина может влиять пользователь через состояние Git-репозитория. 

Также эти файлы должны как-то появиться на узле с Vault, что может быть проблемой в закрытом контуре. Обычный terraform init может не работать, нужны будут ухищрения с локальными registry или кешами. Да и вообще, история с тем, что происходит какой-то exec на сервере с Vault далеко не всем понравится.

Я попробовал собрать всё вместе в один бинарник. После некоторого времени изысканий и патчинга Terraform мне удалось создать монолитный файл, который включал в себя и плагин vault-plugin-gitops, и Terraform, и плагин terraform-provider-vault. Но размер в пару сотен мегабайт выглядел неприятно. Да и это не решало проблему полностью — все возможные плагины Terraform-провайдеров в один бинарник не скомпилируешь, всегда будет чего-то не хватать.

Даже если предположить, что мы сможем как-то проверить и провалидировать все бинарные файлы, основной проблемой было то, что Terraform запускался как дочерний процесс Vault и имел доступ ко всему, к чему имеет доступ сам Vault. А изменение состояния репозитория фактически позволяло управлять кодом, который запускается на сервере. И, хотя данные на диске зашифрованы, в некоторых конфигурациях это остаётся проблемой. 

Чтобы уменьшить риски, я добавил в vault-plugin-gitops режим sandbox для изоляции запуска. Это песочница, создаваемая с помощью cgroups, в которой запускается Terraform и его дочерние процессы.

Каждый запуск Terraform из плагина полностью изолирован, у него свой PID namespace, своя distroless-подобная файловая система, и доступ только к файлам из Git-репозитория. Общий только сетевой неймспейс, потому что Terraform должен взаимодействовать с сетью. Но всё равно пользователь, получивший возможность управлять Git-репозиторием и добавлять в него подписи, потенциально получает и возможность запускать (хоть и непродолжительно, и в изолированной среде) произвольный код на сервере с Vault.

А что, если я просто хочу управлять только конфигурацией Vault через подписи коммитов? Мне не интересно управление облаками и другие возможности Terraform. Да и вообще, его синтаксис мне не принципиален. Хотелось бы чего-то такого же гибкого, но более легковесного. И главное — с более узкими рамками безопасности.

Что мне не нравится в Terraform:

  • Нотация со скобками и «=», потому что я больше привык к YAML.

  • Необходимость задавать имена всем ресурсам. Даже если ресурс один, я вынужден придумывать какие-то названия, и в итоге доходит до resource1, resource2 и т. д.

  • Хотя подразумевается, что Terraform декларативный, всё равно периодически есть проблемы с состоянием. Это не проблема самого Terraform, а скорее проблема Vault, но Terraform её до конца не решает. Например, вы создали secret engine database c динамическими секретам и учётку в базе. Базу выключили. Вы не можете просто удалить secret engine, потому что не может отозваться lease (не может удалиться пользователь из выключенной БД). Когда вы можете влиять на состояние только через Git-коммиты, это вызывает определённую боль.

  • Ну и конечно, необходимость таскать с собой бинарные файлы Terraform и его плагинов.

Что мне нравится в Terraform:

  • Возможность использования значений, полученных из API при создании одних ресурсов, как входных параметров для создания других ресурсов (шаблонизация и outputs).

Потратив какое-то время на перебор вариантов в диапазоне между «гибко-но-сложно» и «вроде-просто-но-ущербно», я придумал очередной метаязык описания конфигурации.

Пример создания парольной политики, пароля с этой политикой и помещения его в секрет kv:

---
# Password policy (e.g. for userpass or dynamic secrets).
path: sys/policies/password/example-policy
data:
  policy: |
    length = 20
    rule "charset" {
      charset = "abcdefghijklmnopqrstuvwxyz"
      min-chars = 1
    }
---
# KV v2 secret engine.
path: sys/mounts/kv-v2 # Имя не обязательно, можно использовать только path.
data:                # В поле data помещаются параметры, 
                     # которые разрешены в openapi spec ресурса.
  type: kv
  description: KV secrets engine version 2
  options:
    version: "2"
---
# Generate password.
path: sys/policies/password/example-policy/generate
# Обычно для создания работает метод POST, но можно указать GET.
method: GET
# Опционально сделать ресурс именованным.
name: userpass-password 
# Список зависимостей.
dependencies:           
  - sys/policies/password/example-policy
# Опциональное поле, позволяет "перегенерировать" ресурс принудительно.
revision: 1
data: {}
---
path: my-secrets/data/my-data
# В зависимостях может быть путь, а может быть имя ресурса.
dependencies:
  - sys/mounts/kv-v2
  - userpass-password
data:
  data:
    password: <userpass-password:password> # Тут используется шаблон.
# Игнорировать неуспешность создания или удаления ресурса.
ignore_failures: true

Концепция та же: с Git, ключами PGP и проверкой подписей. Только конфигурация хранится не в TF-файлах, а в YAML, и применяет её не Terraform + Terraform provider, а плагин самостоятельно. В этой реализации есть аналог состояния (state), которое в неизвлекаемом виде хранится внутри хранилища плагина. Это позволяет, во-первых, не пытаться изменять неизменённые в коммите ресурсы, а во-вторых — обращаться к данным state для создания других ресурсов.

Логика чуть отличается от Terraform. Последний читает состояние всех ресурсов, а затем сравнивает со state и текущей конфигурацией. Если есть различия, применяется текущая конфигурация. Это приводит к тому, что если в Vault поменяют что-то из описанного в Terraform, то либо Terraform это перепишет, либо его заклинит и потребуется импорт ресурсов.

Здесь же я опирался на то, что применяется как будто diff от предыдущего подписанного коммита. Если мы успешно применили коммит, сохранили результат в state и не меняли ресурс в Git, то плагин не будет пытаться поправить этот ресурс в Vault. Даже если его там по какой-то причине поменяли позже через API. Поведение можно изменить, установив флаг revision: тогда ресурс будет применён принудительно.

Зачем так сделано? Отчасти из-за упрощения. Например, в Terraform-провайдере для Vault есть ресурс «сертификат». Но в Vault API такого ресурса нет, вместо него есть метод API «создать сертификат». Если каждый раз вызывать метод (а наш плагин работает в цикле), то каждый раз будет создаваться и новый сертификат. Это, очевидно, не то, что хотелось бы получить, поэтому сертификат создаётся один раз. А если нам нужен другой, то мы или создаём новый ресурс, или выставляем поле revision у имеющегося ресурса (например, увеличив его на 1).

Второй момент — уменьшение количества запросов в API. В отличие от Terraform, плагин не читает ресурсы, которые создаёт, чтобы убедиться, что они не изменились. Достаточно create и, возможно update, если ресурсы предполагается обновлять.

Давайте посмотрим на плюсы и минусы варианта без Terraform.

В чём плюсы:

  • Для работы с кодом требуется только библиотека YAML, а для работы с API — vault/api. Долой 100 мегабайт Terraform, 50 мегабайт провайдера! Ну и заодно долой недоступность registry Terraform из России. И, кстати сказать, registry OpenTofu тоже — не такой уж и open он оказался на деле.

  • Вся обработка реализована прямо в плагине: нет никаких exec, не нужно думать про проверку контрольных сумм Terraform и provider.

  • Плагин можно сделать builtin в вашей сборке Vault, и тогда не будет вообще никаких внешних зависимостей.

  • В отличие от версии с Terraform, все операции происходят без необходимости сохранять файлы на диск — плагин работает с Git-каталогом прямо в памяти. То есть работает, например, в distroless-контейнере с readonly rootfs.

  • Конфигурация максимально повторяет спецификацию OpenAPI, никакой отсебятины. Можно просто копировать рабочие запросы из API-Explorer (в UI Vault) или вывод команды vault -output-curl-string в поле data конфигурации.

  • Можно легко конвертировать YAML в JSON и обратно (API Vault работает с JSON), в отличие от потенциально проблемной конвертации из JSON в HCL. Конечно, если вы не пишете все свои TF-файлы с помощью ресурсов vault_generic_secret.

  • Есть возможность произвольно разбивать ресурсы на файлы и папки. Плагин поддерживает рекурсивный обход и поиск файлов .yml/.yaml, а также их агрегацию.

  • Наличие линтера, в том числе в виде отдельной утилиты. Можно запустить gitops-tool lint . и приложение проверит ваши YAML-конфигурации на корректность, дубли ресурсов или нарушенные зависимости.

  • Более продвинутый тест — можно запустить локально vault server -dev и выполнить gitops-tool test .. В этом случае утилита попытается реально создать все ресурсы в тестовом Vault-сервере. Если встроить этот функционал в CI, то вы сможете отловить потенциальные ошибки конфигурации ещё до того, как начнёте подписывать коммиты или вообще читать PR.

В чём минусы:

  • Помните мем про «У нас есть 14 конкурирующих стандартов»? Похоже, мы тут придумали пятнадцатый.

  • Конечно же, пропала магия Terraform, когда можно безопасно управлять «какой угодно инфраструктурой» прямо из Vault, на основе состояния Git и кворума подписей. Осталась только конфигурация Vault.

Таким образом мы избавились от необходимости запуска и проверки бинарных файлов Terraform или OpenTofu, а также от провайдеров. Режим работы плагина сделан настраиваемым — с Terraform или без него — и регулируется при включении mount path в Vault.

Плагин можно собрать с билд-тегом no_terraform. Такая сборка уже присутствует в релизах, в ней полностью отключён Terraform, есть только возможность доступа к API Vault. Причём только с той политикой, которая задана для токена, который вы поместили в плагин.

Вместо заключения

Есть разные подходы к построению безопасной инфраструктуры. Классический — давайте сначала ограничим доступ везде и оставим его только тем, кому можно. Но такой вариант не закрывает случаи, когда доступы для «тех, кому можно» скомпрометированы. Подобрали пароль, утёк сертификат VPN, нашли уязвимость в GitLab и т. д. 

Здесь же мы рассматриваем ситуацию, что хранилище секретов может работать в заведомо небезопасной среде, и это не повлияет на безопасность данных. 

Хакеры могут сделать сколько угодно фейковых коммитов в Git, администратор GitLab может обидеться на низкую зарплату и поправить CI/CD-пайплайн, у одного, а то и у двух сотрудников с помощью социальной инженерии могут украсть ключи подписи или ключи SSH. Администратор виртуализации может дампить память ваших серверов и искать там пароли (как защититься от этого, мы рассказывали в статье «Добавляем паранойи: двойное шифрование секретов»). 

Во всех этих случаях при должном подходе система всё ещё будет оставаться нескомпрометированной. Управление конфигурацией с помощью кворума позволяет избежать потенциальных проблем до тех пор, пока большинство учётных записей «администраторов» не скомпрометированы, и закрывает проблему единого мастер-ключа.

Конечно, очень важна надёжность среды исполнения. Если вместо легитимного Vault вы будете запускать подделку, то средствами Vault от этого защититься уже проблематично (если вы знаете интересные методы — прошу поделиться в комментариях). Поэтому один из вариантов поставки Deckhouse Stronghold, нашего решения для безопасного управления жизненным циклом секретов, которое начиналось с форка HashiCorp Vault, — поставка в составе платформы контейнеризации.

Deckhouse Kubernetes Platform выполняет проверку легитимности запускаемого ПО на всех этапах — от проверки хешей образов до проверки подписи бинарных файлов. Также она развёртывает исполняемые файлы контейнеров в EROFS (Enhanced Read-Only File System), что исключает возможность их подмены в рантайме даже администратором хост-системы. 

Кстати, описанный в статье подход управления конфигурациями доступен в Deckhouse Stronghold. В том числе и в соответствующем модуле нашей Open Source-платформы Deckhouse Kubernetes Platform Community Edition.  

P. S. 

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