В этой статье хотелось бы поделиться практикой использования хранилища секретов от компании Hashicorp, и называется оно Vault.

Расскажу о том, как в нашей компании используется данное хранилище, опустив детали установки и масштабирования. Не будем говорить о мониторинге и отказоустойчивости. Также пройдем мимо темы восстановления хранилища после катастрофы. Все это темы отдельных статей.

Всем здравствуйте, меня зовут Сергей Андрюнин.

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

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

Содержание

Введение

Знакомьтесь, Hashicorp Vault

Методы авторизации

Авторизации методом AppRole

Использование

От теории к практике

Выводы

Введение

Прежде чем начать описывать разные механизмы авторизации и их плюсы и минусы, поговорим сперва зачем вообще оно нужно – то самое хранилище.

В современном мире большинство веб приложений и различных сервисов либо уже перешли на микросервисную архитектуру, либо в активной стадии перехода. В связи с тем, что вместо одного монолитного файла конфигурации мы получаем огромное количество мелких (на каждый сервис свой или даже несколько), возникает потребность хранить содержимое этих файлов максимально безопасно. А бывает еще и так, что вместо файла конфигурации у нас переменные окружения используются для конфигурации сервиса. В итоге на каждый сервис у нас появляются свои настройки подключения к БД, внешним API, очередям сообщений, кэшам и другим системам. Помимо этого, мы можем конфигурировать и другие параметры, которые хотели бы держать в секрете. Например, соль (модификатор входа хэш-функции) для шифрования, ключи для генерации JWT-токенов. Одним словом, у нас появляется огромное количество сущностей, которые мы хотим хранить и желательно так, чтобы у посторонних к ним не было доступа. И тут нам на помощь приходит продукт от компании Hashicorp.

Знакомьтесь, Hashicorp Vault

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

  • AppRole

  • AliCloud

  • AWS

  • Azure

  • Cloud Foundry

  • GitHub

  • GoogleCloud

  • JWT/OIDIC

  • Kerberos

  • Kubernetes

  • LDAP

  • Oracle Cloud

  • Okta

  • Radius

  • TLS

  • Tokens

  • Basic

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

В этой статье мы немного поговорим лишь о некоторых, а на одном из них остановимся более подробно. Чуть дальше поймете, почему.

Методы авторизации

Чаще всего в WEB-интерфейс мы авторизуемся либо через токен доступа, либо с помощью логина и пароля, которые могут быть внутренними или храниться на Radius или LDAP-сервере.

Авторизуемся в хранилище с помощью логина и пароля:

$ vault login -method=userpass username=admin password=strongPASS

То же самое, но с использованием curl:

$ curl --request POST --data '{"password": "strongPASS"}' http://vault_addr/v1/auth/userpass/login/admin

При использовании vault cli инструмента чаще всего применяется токен для авторизации в хранилище, который сохраняем в специальную переменную среды VAULT_TOKEN.

$ export VAULT_ADDR='http://10.10.10.10:8200/'
$ export VAULT_TOKEN=<token>
$ vault login [token=<token>]

В последней команде опция token является не обязательной при условии, что мы установили VAULT_TOKEN в переменные окружения.

Для выполнения запросов через API из приложения или из утилиты curl используется все тот же токен. Либо с помощью нашей учетной записи мы генерируем новый коротко живущий токен и используем его. Все подробности по работе с токенами можно найти тут.

И еще один способ для взаимодействия с API – это авторизация через AppRole. Для того чтобы выполнить авторизацию в хранилище, нам нужно знать наш App-ID и Secret-ID. Что гораздо безопаснее, чем логин/пароль и токен доступа.

Авторизация методом AppRole

Вольный перевод документации гласит:

Метод авторизации Approle позволяет компьютерам или приложениям проходить аутентификацию с помощью ролей, определенных Vault. Этот метод аутентификации ориентирован на автоматизированные рабочие процессы (машины и сервисы) и менее полезен для людей-операторов.

AppRole представляет собой набор политик Vault и ограничений входа в систему, которые должны быть соблюдены для получения токена с этими политиками. AppRole может быть создан для конкретной машины или даже для конкретного пользователя на этой машине, или для службы, распределенной по машинам. Учетные данные, необходимые для успешного входа в систему, зависят от ограничений, установленных для AppRole.

Конфигурация Vault

Для того чтобы начать использовать данный метод, необходимо сперва настроить наше хранилище. Для этого выполним несколько простых команд.

Перед началом работы с хранилищем настроим параметры подключения к нему.

$ export VAULT_ADDR=http://10.10.10.10:8200
$ export VAULT_TOKEN=your_token_here

Включаем нужный метод авторизации.

$ vault auth enable approle

Затем добавим тестовые данные, именно с ними мы будем работать в будущем.

$ vault kv put secrets/demo/app/service db_name="users" username="admin" password="passw0rd"

Для дальнейшего конфигурирования данного метода авторизации можно использовать root token. Что само по себе не безопасно и не рекомендуется. Хорошей практикой является создавать отдельные учетные записи для каждого инженера или, например, включить авторизацию через LDAP.

Чтобы пользователь смог выполнять действия по дальнейшему конфигурированию, необходимо назначить для него следующую политику:

# Mount the AppRole auth method
path "sys/auth/approle" {
  capabilities = [ "create", "read", "update", "delete", "sudo" ]
}

# Configure the AppRole auth method
path "sys/auth/approle/*" {
  capabilities = [ "create", "read", "update", "delete" ]
}

# Create and manage roles
path "auth/approle/*" {
  capabilities = [ "create", "read", "update", "delete", "list" ]
}

# Write ACL policies
path "sys/policies/acl/*" {
  capabilities = [ "create", "read", "update", "delete", "list" ]
}

# Write test data
# Set the path to "secrets/data/demo/*" if you are running `kv-v2`
path "secrets/demo/*" {
  capabilities = [ "create", "read", "update", "delete", "list" ]
}

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

Перед тем как создать саму роль нам нужно определить для нее политику доступа. А именно, что и на каком уровне доступа мы можем делать в хранилище. В нашем случае разрешим читать созданный ранее секрет.

$ vault policy write -tls-skip-verify app_policy_name -<<EOF
# Read-only permission on secrets stored at 'secrets/demo/app/service'
path "secrets/data/demo/app/service" {
  capabilities = [ "read" ]
}
EOF

Далее нам необходимо создать роль – ту самую роль, с помощью которой мы будем читать секреты:

$ vault write -tls-skip-verify auth/approle/role/my-app-role \
  token_policies="app_policy_name" \
  token_ttl=1h \
  token_max_ttl=4h \
  secret_id_bound_cidrs="0.0.0.0/0","127.0.0.1/32" \
  token_bound_cidrs="0.0.0.0/0","127.0.0.1/32" \
  secret_id_ttl=60m policies="app_policy_name" \
  bind_secret_id=false

Поверяем, что роль создалась успешно следующей командой:

$ vault read -tls-skip-verify auth/approle/role/my-app-role

Затем получаем role-id. Этот параметр нам пригодится для выполнения авторизации в хранилище.

$ vault read -tls-skip-verify auth/approle/role/my-app-role/role-id

Далее приведу небольшую выдержку вольного перевода из документации.

RoleID - это идентификатор, который выбирает AppRole, по которому оцениваются другие учетные данные. При аутентификации в систему RoleID всегда является обязательным аргументом (через role_id). По умолчанию RoleID - это уникальные UUID, которые позволяют им служить вторичными секретами для другой информации об учетных данных. Однако они могут быть установлены на определенные значения, чтобы соответствовать интроспективной информации клиента (например, доменному имени клиента).

Теперь рассмотрим параметры, которые нам необходимы для создания роли.

  • role_id – обязательные учетные данные в конечной точке входа. Для AppRole, на который указывает role_id будут наложены ограничения.

  • bind_secret_id – требует обязательно или нет предоставлять secret_id в точке регистрации. Если значение будет true то Vault Agent не сможет авторизоваться. Параметр задается при создании роли.

Дополнительно можно настроить и другие ограничения для AppRole. Например, secret_id_bound_cidrs будет разрешено входить в систему только с IP-адресов, принадлежащих настроенным блокам CIDR на AppRole.

Документацию по AppRole API можно прочесть тут.

Использование

Для авторизации можно использовать 2 метода. Первый метод заключается в передаче полного набора параметров необходимых для авторизации. Затем получение токена либо временно оборачивающего токена для того, чтобы получить основной токен. Как правило, оборачивающий токен делают коротко живущим, чтобы, например, произвести выкладку приложения на сервер и получить основной токен. Затем основной токен сохранить в память и использовать, в это время оборачивающий токен будет уже не валиден. И второй вариант более хитрый и более безопасный, который мы используем в нашей компании. Рассмотрим оба варианта подробнее.

Вариант 1

RoleID эквивалентен имени пользователя, а SecretID - соответствующему паролю. Приложению необходимо и то, и другое для входа в Vault. Естественно, следующий вопрос заключается в том, как безопасно доставить эти секреты клиенту.

Например, Ansible может использоваться как доверенный способ доставки RoleID в среду, где запускается приложение. Рисунок, взятый из официальной документации, показывает наглядно весь происходящий процесс.

Для получения wrap token, который будет использоваться при авторизации и запросе secret-id, выполните команду:

$ vault write -wrap-ttl=600s -tls-skip-verify -force auth/approle/role/my-app-role/secret-id

Затем полученный файл необходимо поместить на файловую систему виртуальной машины, где запускается наше приложение. Этот путь должен совпадать с тем, который ожидает приложение. Полученный токен может быть использован лишь один раз. После запуска токен будет прочитан и использован для получения secret-id. После получения secret-id приложение может запросить данные из секретного хранилища Vault, пока не истек TTL для secret_id. Данный способ хорошо подходит в том случае, когда мы заполняем начальную конфигурацию приложения на этапе запуска. Например, применив паттерн Singleton, заполняем значениями структуру Config.

Рекомендации гласят, что wrap token должен иметь время жизни равное времени деплоя приложения. В то же время secret-id могут иметь длинное время жизни, но это не рекомендуется разработчиком Vault в силу большого потребления памяти и нагрузке при очистке истекших токенов. Лучшая рекомендация гласит, что нужно использовать короткое время жизни и постоянно продлевать его. Тем самым избегать дорогих операций авторизации и выдачи новых токенов.

Вариант 2

Для получения secret-id можно использовать vault-agent, который позволяет, зная только role-id, авторизоваться и получить token доступа в хранилище. При этом агент берет на себя функции по обновлению данного токена. Агент может поставлять как wrap token, так и готовый к использованию secret-id.

Подготовка конфигурации агента vault-agent.hcl. В данной конфигурации для демонстрации мы включим запись полученных токенов на диск. В результате мы получим два вида токена wrapped и уже готовый к применению обычный secret-id. Хочу отметить, что записывать токены на диск не обязательно.

pid_file = "./pidfile"

auto_auth {
  mount_path = "auth/approle"
  method "approle" {
    config = {
      role_id_file_path = "./roleID"
      secret_id_response_wrapping_path = "auth/approle/role/my-app-role/secret-id"
    }
  }

  sink {
    type = "file"
    wrap_ttl = "30m"
    config = {
      path = "./token_wrapped"
      }
    }

  sink {
    type = "file"
    config = {
      path = "./token_unwrapped"
      }
    }
}

vault {
  address = "https://10.10.10.10:8200"
}

Запускаем Vault Agent.

$ vault agent -tls-skip-verify -config=vault-agent.hcl -log-level=debug

После запуска агента на файловой системе появятся два файла согласно нашей конфигурации. Первый файл будет в json формате и содержать данные wrap token для получения secret-id. Второй файл будет содержать secret-id готовый к применению для авторизации. Агент берет на себя функционал по обновлению данных токенов.

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

От теории к практике

В качестве примера мы попробуем сконфигурировать демо-приложение и сервер nginx. Для сокращения размера статьи я опущу некоторые подробности, но предоставлю ссылку на репозиторий , где можно все посмотреть самому.

Vault для быстроты мы развернем в Kubernetes кластере. Можно использовать Minikube или Docker Desktop для Mac, включив там опцию Kubernetes Cluster. Все необходимые манифесты расположены в репозитории по ссылке выше в папке manifests.

Вы также можете запустить хранилище vault на своей рабочей машине. Для этого нужно скачать бинарный файл и запустить его.

Пишем демонстрационные данные в хранилище.

$ vault kv put secrets/demo/app/nginx responseText="Hello from Vault"

Создаем политику.

$ vault policy write -tls-skip-verify nginx_conf_demo -<<EOF
# Read-only permission on secrets stored at 'secrets/demo/app/nginx'
path "secrets/data/demo/app/nginx" {
  capabilities = [ "read" ]
}
EOF

Создаем роль.

$ vault write -tls-skip-verify auth/approle/role/nginx-demo \
  token_policies="nginx_conf_demo" \
  token_ttl=1h \
  token_max_ttl=4h \
  secret_id_bound_cidrs="0.0.0.0/0","127.0.0.1/32" \
  token_bound_cidrs="0.0.0.0/0","127.0.0.1/32" \
  secret_id_ttl=60m policies="nginx_conf_demo" \
  bind_secret_id=false

Получаем role-id.

$ vault read -tls-skip-verify auth/approle/role/nginx-demo/role-id

Далее в папке demo расположены манифесты. В папке nginx лежит все, что нужно для работы нашего примера. Также там есть еще папка app, которая запустит демонстрационное демо приложение, написанное на go, которое прочитает наши секреты, подготовленные в первой теоретической части.

Я лишь остановлюсь на пояснениях к манифестам для nginx.

Как мы можем увидеть из deployment, у нас несколько контейнеров.

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-autoreload
  labels:
    role: nginx-reload-test
...

      initContainers:
        - name: init-nginx-config
          image: vault:1.9.0
          imagePullPolicy: IfNotPresent
...
      containers:
        - name: nginx
          image: nginx:1.21.4-alpine
          imagePullPolicy: IfNotPresent
...
        - name: vault-agent-rerender
          image: vault:1.9.0
          imagePullPolicy: IfNotPresent
...
      volumes:
        - name: vault-nginx-template
          configMap:
            name: vault-nginx-template
        - name: vault-agent-config
          configMap:
            name: vault-agent-configs
        - name: nginx-rendered-conf
          emptyDir:
            medium: Memory

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

Вот самая интересная часть конфига:

template {
          source      = "/etc/vault/config/template/nginx/nginx.conf.tmpl"
          destination = "/etc/vault/config/render/nginx/nginx.conf"
          command = "ps ax | grep 'nginx: maste[r]' | awk '{print $1}' | xargs kill -s HUP"
    }
    template_config {
          static_secret_render_interval = "1m"
    }

Тут мы говорим агенту отслеживать изменения в хранилище раз в 1 минуту. Можно и реже, можно и чаще, зависит от ваших задач. Затем, если происходит рендер конфига, то мы выполняем следующую команду, а именно отправляем HUP мастер процессу nginx, и тот в свою очередь завершает активные подключения и запускает новый воркер с новой конфигурацией. Таким образом мы ее мягко перечитываем.

Теперь, если мы изменим нашу фразу в хранилище, то мы перезапишем конфиг и заставим nginx перечитать его и отдавать уже новые данные.

В качестве эксперимента с доступом из определенного CIDR в момент, когда ваше приложение работает, вы можете перезаписать роль, с которой авторизуется агент. Например, вот так:

$ vault write -tls-skip-verify auth/approle/role/nginx-demo \
  token_policies="nginx_conf_demo" \
  token_ttl=1h \
  token_max_ttl=4h \
  secret_id_bound_cidrs="192.168.17.0/24","127.0.0.1/32" \
  token_bound_cidrs="192.168.17.0/24","127.0.0.1/32" \
  secret_id_ttl=60m policies="nginx_conf_demo" \
  bind_secret_id=false

Заменив подсеть 0.0.0.0 на 192.168.17.0/24 или любую другую, в которой не запущено ваше приложение. После изменения политики агент больше не сможет авторизоваться в хранилище и получать данные. Чтобы проверить это, измените строку в секретах еще раз и посмотрите в логи агента. Там вы увидите сообщение о том, что авторизация невозможна.

Выводы

В чем же здесь заключается секрет? А вот в чем: при создании роли мы устанавливаем параметры secret_id_bound_cidrs и token_bound_cidrs, ограничивающие подсети, с которых мы можем получать secret-id и token. Иными словами, Vault будет отклонять запросы из других сетей и получить доступ к данным не получится. Для того чтобы сделать все по максимуму, при создании роли мы установим параметр bind_secret_id=false. Это позволит авторизоваться в хранилище, не передавая secret-id. Vault его получит за нас.

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

Хочется добавить одну важную особенность. Для того, чтобы получить доступ к хранилищу через AppRole, указав только role-id, нужно производить авторизацию через vault agent. На момент написания статьи библиотека для доступа к хранилищу для GO не поддерживала такой способ и требовала еще secret-id.

Следовательно, тут возникают архитектурные особенности построения приложений, которые в данной статье не рассматриваются. Но если быть кратким, то через агента можно заполнять env переменные для приложения и уже дальше работать с ними. Или же второй вариант - приложение само получает токен доступа при деплое через wrapped token и уже самостоятельно читает секреты из vault, имея на руках secret-id, который хранится в памяти и используется столько сколько нужно раз.

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

Если вам понравилась статья и вы хотите продолжения, например, о том, как создать и работать с PKI, как мониторить и масштабировать кластер, как выполнять резервное копирование и восстановление, то поддержите нас лайком и непременно сообщите об этом в комментарии.

У вас появились вопросы по реализации данного метода авторизации? Не стесняйтесь, оставляйте их в комментариях. Если вы считаете другой способ более оптимальным поделитесь им в комментариях, читателям будет полезно знать несколько способов работы с секретами.

Комментарии (10)


  1. S1M
    04.08.2023 01:41
    +3

    "auth/approle/*" { capabilities = [ "create", "read", "update", "delete", "list" ] }

    path "sys/policies/acl/*" { capabilities = [ "create", "read", "update", "delete", "list" ] }

    Возможно я где-то что-то проглядел, но получается, что пользователь может сам себе политику поменять на любые права и забиндить любую роль.


    1. sandryunin Автор
      04.08.2023 01:41

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

      Для дальнейшего конфигурирования данного метода авторизации можно использовать root token. Что само по себе не безопасно и не рекомендуется. Хорошей практикой является создавать отдельные учетные записи для каждого инженера или, например, включить авторизацию через LDAP.

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

      Естественно, что политики создают админы, и "create" как правило выдавать нужно не везде. Но мы даем разрешению пользователю с админискими правами.


  1. mc2
    04.08.2023 01:41

    command = "ps ax | grep 'nginx: maste[r]'... можно заменить на pkill


    1. sandryunin Автор
      04.08.2023 01:41
      -1

      если она есть в контейнере, но нам процесс убивать не нужно, нам нужно отправить ему сигнал


      1. citius
        04.08.2023 01:41

        `nginx -s reload`?


        1. sandryunin Автор
          04.08.2023 01:41

          вот лишь бы написать, я конечно понимаю что вы не внимательно читали, команда выполняется из контейнера с вольтом, там нет никакого nginx, ровно как и pkill, если смотреть манифест то там контейнерам разрешен shareProcessNamespace: true

          это значит что один контейнер может видеть процессы другого в рамках пода, и все что мы можем сделать это взаимодействовать через сигналы, отправка сигнала HUP равна nginx -s reload, но вызвать его напрямую из контейнера с вольтом мы не можем.

          Не разобравшись в теме, еще и минусят карму, вот зачем не понимаю?


        1. sandryunin Автор
          04.08.2023 01:41

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


      1. mc2
        04.08.2023 01:41

        ну уж тогда сократить до ps ax | awk '/nginx: maste[r]/{system("kill -HUP "$1}'


        1. sandryunin Автор
          04.08.2023 01:41

          соглашусь, вариант хороший


  1. ilyagoz
    04.08.2023 01:41

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

    То есть любое другое приложение, работающее в той же подсети, сможет авторизоваться, воспользовавшись утекшим role_id?