Введение
Привет, Хабр! В этой статье я хотел бы поделиться опытом нашей команды в части интеграции .NET Core и выше приложений с корпоративным хранилищем секретов HashiCorp Vault.
Но сначала немного о себе. Я работаю разработчиком в АльфаСтрахование – в команде, которая занимается электронным документооборотом. Наша команда уже рассказывала о своих решениях на хабре (статьи про Odata, интеграцию с Почтой России, а также про распознавание подписи).
Наши приложения, как и большинство приложений в компании, используют в процессе работы различные секреты (пароли, токены и т.д.). Их, с точки зрения информационной безопасности, нельзя хранить в коде или на компьютере, на котором запущено приложение. Поэтому в компании используется специальное ПО – хранилище секретов HashiCorp Vault.
Ниже приведен пример секрета в интерфейсе HashiCorp Vault (путь к секрету скрыт):
В чем суть проблемы, с которой мы столкнулись: решение, используемое ранее для получения секретов из HashiCorp Vault в приложение, было не очень удобным и очевидным.
В 1-й части статьи я расскажу про ранее используемое решение (далее Vault Integration 1.0), его плюсы и минусы. А во 2-й части уже буду описывать выработанное нами улучшение решения (далее Vault Integration 2.0).
Vault Integration 1.0
Клиент к Vault
Для взаимодействия с HashiCorp Vault используется специализированное API. И прежде чем думать над интеграцией необходимо создать клиент, который банально сможет сходить в хранилище секретов и подтянуть оттуда секреты в приложение.
Основой для нашего клиента послужила общедоступная библиотека для взаимодействия с хранилищем секретов VaultSharp. Однако мы решили не использовать её напрямую, а создали поверх нее свою надстройку.
Надстройка понадобилась нам для того чтобы ограничить множество функциональных возможностей, которые предлагаются исходной библиотекой. Ведь, в сухом остатке, нам нужна будет только возможность получения секрета. Поэтому мы создали интерфейс поверх этого клиента, в котором указываем путь к секрету и ключ секрета, который хотим получить.
/// <summary>
/// Клиент для работы с HashiCorpVault
/// </summary>
public interface IHashiCorpVaultClient
{
/// <summary>
/// Возвращает результат выполнения запроса к хранилищу со словарём всех доступных значений для секрета
/// </summary>
/// <param name="secretPath">Путь к секрету</param>
/// <returns>Результат выполнения запроса к хранилищу со словарём всех доступных значений для секрета</returns>
Task<IDictionary<string, object>> GetSecretValuesAsync(string secretPath);
}
Секреты в конфигурации приложения
Мы используем стандартный механизм конфигурирования приложений .NET Core. Поэтому основной проблемой при использовании этого инструмента было положить секрет в конфигурацию.
Для решения этой проблемы в конфигурацию была внедрена особая секция:
{
"Endpoints": {
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
"VaultEnv": "prod",
"VaultSecretsPath": {
"RootPath": " root-folder/",
"TechUsersPath": "tech-users/",
}
}
}
}
Она называется VaultSecretsPath, как видно из примера. В ней перечислены пути к различным секциям в хранилище секретов.
Суть решения проста: при старте приложения на отдельные места в конфигурации мы накладываем дополнительные действия. Они берут указанные в VaultSecretsPath секции, строят пути к требуемым секретам, получают и устанавливают эти секреты в определенные свойства в конфигурации.
Для простоты восприятия рассмотрим это на конкретном примере:
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login"
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
"VaultEnv": "prod",
"VaultSecretsPath": {
"RootPath": "root-folder/",
"TechUsersPath": "tech-users/",
}
}
}
}
Тут мы добавили настройки клиента к некоторому сервису и указали у него логин от определенной учетной записи (далее УЗ). Когда будет запущено приложение, произойдут следующие действия:
Построится путь к секрету от УЗ root-folder/tech-users/example-login.
Используя построенный путь, будет получен пароль от УЗ из хранилища секретов
Пароль запишется в свойство Password
Ниже приведен пример кода, который использовался для подобного рода получения секретов:
/// <summary>
/// Добавляет к имеющейся в DI конфигурации учетные данные, полученные из Vault
/// </summary>
/// <typeparam name="TConfig">Тип конфигурации</typeparam>
/// <param name="services">Коллекция служб</param>
/// <returns>Коллекция служб</returns>
public static IServiceCollection AddCredentialsFromVault<TConfig>(
this IServiceCollection services)
where TConfig : BaseClientConfig
{
services
.AddOptions<TConfig>()
.PostConfigure<IOptions<HashiCorpVaultConfig>, IHashiCorpVaultClient>(
(config, vaultOptions, vaultClient) =>
{
ClientCredentials credentials = config.Credentials;
string vaultSecretPath = GetTechUserVaultPath(config, vaultOptions.Value);
config.Credentials.AddSecretsFromVault(vaultClient, vaultSecretPath);
});
return services;
}
/// <summary>
/// Возвращает путь до секретов тех. УЗ
/// </summary>
/// <typeparam name="TConfig">Тип конфигурации</typeparam>
/// <param name="config">Конфигурация клиента, для которой нужен путь</param>
/// <param name="hashiCorpVaultConfig">Конфигурация клиента HashiCorpVault</param>
/// <returns>Путь до секретов тех. УЗ</returns>
private static string GetTechUserVaultPath<TConfig>(TConfig config, HashiCorpVaultConfig hashiCorpVaultConfig)
where TConfig : BaseClientConfig
{
return new StringBuilder(hashiCorpVaultConfig.VaultSecretsPath.RootPath)
.Append(hashiCorpVaultConfig.VaultSecretsPath.TechUsersPath)
.Append(config.Credentials.Username)
.ToString();
}
/// <summary>
/// Дополняет учетные данные секретами из Vault
/// </summary>
/// <param name="clientCredentials">Учетные данные</param>
/// <param name="vaultClient">Клиент Vault</param>
/// <param name="vaultSecretPath">Путь к секрету</param>
/// <returns>Дополненные учетные данные</returns>
public static ClientCredentials AddSecretsFromVault(
this ClientCredentials clientCredentials,
IHashiCorpVaultClient vaultClient,
string vaultSecretPath)
{
var result = vaultClient.GetSecretValuesAsync(vaultSecretPath).Result;
clientCredentials.Password = result["password"].ToString();
return clientCredentials;
}
Плюсы и минусы Vault Integration 1.0
При работе с данным решением мы выявили следующие достоинства и недостатки:
ДОСТОИНСТВА |
НЕДОСТАТКИ |
Все секреты вынесены из кода в HashiCorp Vault (ради этого все и затевалось) Понятно как используя это решение добавлять новые секреты |
Непонятно какой путь к секрету будет сформирован под капотом В конфигурации не видно, что вообще существует такое свойство как Password, а тем более, что в него будет установлен секрет Решение сложно расширять, используя новые секции в свойстве VaultSecretsPath, т.к. это требует доработки кода Существует сильная зависимость между различными свойствами в конфигурации При администрировании секретов в HashiCorp Vault необходимо учитывать структуру заданную секциями в свойстве VaultSecretsPath |
Раньше минусы 1-й версии нашей интеграции особо не беспокоили. Но со временем выросло количество различных систем, с которыми нужно было взаимодействовать. А секреты у этих систем различались, из-за чего стало много разнообразных как путей, так и ключей к секретам. Как результат, держать всё это в голове стало совсем сложно.
Vault Integration 2.0
Варианты решения
Эта ситуация натолкнула нас на поиск альтернативных решений для интеграции с HashiCorp Vault и мы начали накидывать варианты.
Вариант 1:
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login",
"Password": "vault> root-folder/tech-users/example-login password"
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
Первое, что мы придумали, это сделать ссылку на секрет непосредственно в свойстве. Тут мы указываем команду vault>, путь к секрету и ключ секрета.
Главным плюсом этого решения для нас стала возможность записи в одну строчку. Однако нам не нравилось, что происходило затирание информации о секрете после подтягивания его значения в свойство. Из-за этого обновление секрета во время работы приложения могло стать проблемой.
Вариант 2:
{
"VaultSecrets":[
{
"SecretPath":"root-folder/tech-users/example-login",
"SecretKey":"password",
"ConfigPropPath": "Endpoints:ClientConfig:Credentials:Password"
}
],
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login"
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
2-й вариант заключается в ведении списка в конфигурации, где будет вся информация о том куда и какой секрет нужно положить.
Основным плюсом такого подхода является централизация секретов. В конфигурации сразу в одном месте собрана вся информация об используемых секретах. Основной же недостаток, это дублирование информации. Т.е. при получении конфигурации из разных источников (например, appsettings и appsettings.Test) происходит замена данных массива, а значит информацию о всех секретах нужно дублировать во всех источниках данных, будь то appsettings.Test.json файл или SpringCloud конфигурация.
Команда не смогла единогласно выбрать ни одно из предложенных решений, что привело к продолжению поиска вариантов.
Вариант 3:
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login",
"Secret": {
"SecretPath":"root-folder/tech-users/example-login",
"SecretKey":"password",
"PropName":"Password"
}
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
Основной идеей текущего варианта является ссылочная секция. У нас в конфигурации появляется специальная секция, в которой мы настраиваем секрет. Секция называется Secret и содержит в себе информацию о том, куда и какой секрет нужно положить.
Основным недостатком данного варианта стала невозможность загрузки нескольких секретов в рамках одной секции в конфигурации. Однако нам очень понравилась идея ссылочных секций и мы решили её развивать.
Вариант 4:
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login",
"PasswordSecret": {
"SecretPath":"root-folder/tech-users/example-login",
"SecretKey":"password"
}
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
Как развитие предыдущей идеи мы подумали о ссылочной секции, идентификатором которой является окончание Secret. Она содержит в себе путь и ключ секрета, а свойство, в которое будет записан секрет, определяется именем секции, т.е. удалив окончание Secret из наименования секции мы получим имя свойства.
Этот вариант понравился уже больше, но смотря на 1-й вариант, который пишется в одну строчку, было сложно смерится с этим решением.
Вариант 5:
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login",
"Password-VaultSecret": "root-folder/tech-users/example-login password"
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
Как я уже говорил, мы ухватились за идею ссылочных секций, а также не могли смириться с записью получения секрета более чем в одну строчку.
5-й вариант является своего рода объединением 1-го и 4-го вариантов. Вместо ссылочной секции мы перешли к идее ссылочного свойства, которое идентифицировалось окончанием –VaultSecret в наименовании, а значением этого свойства является путь + ключ секрета.
Выбор решения
После обсуждения этих вариантов для выбора одного их нужно было сравнить:
Сравнение вариантов решения | |||||
Достоинства |
в.1 |
в.2 |
в.3 |
в.4 |
в.5 |
Гибкость. Можно быстро изменять как место, из которого секрет получается, так и место, куда он устанавливается |
✅ |
✅ |
✅ |
✅ |
✅ |
Прозрачность. Сразу видно куда и какой секрет будет установлен |
✅ |
|
✅ |
✅ |
✅ |
Простота. Нужна одна строчка для использования функционала |
✅ |
|
|
|
✅ |
Низкая связность. Получение секрета зависит не более чем от одного другого свойства в конфигурации |
✅ |
|
|
|
✅ |
Работа с секретами до сборки конфигурации приложения. Возможность получать секрет в нужное место, до того как будет собрана конфигурация самого приложения |
✅ |
✅ |
✅ |
✅ |
✅ |
Централизация. В одном месте собраны все секреты, что получаются из Vault в конфигурацию |
|
✅ |
|
|
|
Отсутствие парсинга строки. Для получения информации о секрете не нужно раскладывать строку на составляющие |
|
✅ |
✅ |
|
|
Сохранение информации о секрете. После установки секрета в свойство не затирается путь/ключ секрета |
|
✅ |
✅ |
✅ |
✅ |
Отсутствие дублирования. При загрузке конфигурации из разных источников не нужно дублировать информацию о секретах в них |
✅ |
|
✅ |
✅ |
✅ |
Множественное использование. Отсутствие ограничения на количество секретов, которые можно получить в рамках одной секции в конфигурации |
✅ |
✅ |
|
✅ |
✅ |
5-й вариант решения побеждал при сравнении и сразу всем понравился, поэтому мы быстро его выбрали и пошли реализовывать.
Реализация Vault Integration 2.0
Для конфигурирования .NET Core+ приложений основными инструментами получения данных являются поставщики конфигураций. Поэтому мы начнем с реализации поставщика для наших нужд.
/// <summary>
/// Поставщик данных из корпоративного хранилища секретов Vault
/// </summary>
public class HashiCorpVaultConfigurationProvider : ConfigurationProvider
{
/// <summary>
/// Окончание наименования ключа, которое должно содержать ссылочное свойство в конфигурации
/// </summary>
private const string EndCommand = "-VaultSecret";
/// <inheritdoc cref="HashiCorpVaultConfigurationSource"/>
private readonly HashiCorpVaultConfigurationSource _source;
/// <summary>
/// Конструктор
/// </summary>
/// <param name="source">Источник для поставщика данных из корпоративного хранилища секретов Vault</param>
public HashiCorpVaultConfigurationProvider(HashiCorpVaultConfigurationSource source)
{
_source = source;
}
/// <summary>
/// Загружает секреты из корпоративного хранилища в Vault в конфигурацию
/// </summary>
public override void Load()
{
if (_source.Secrets.IsNullOrEmpty())
return;
foreach(ReferenceVaultSecretConfig secretConfig in _source.Secrets.Values)
DownloadSecret(secretConfig);
}
/// <summary>
/// Добавляет значение в конфигурацию. Если ключ содержит "-VaultSecret", парсит значение строку
/// </summary>
/// <inheritdoc />
public override void Set(string key, string value)
{
// Если это не наш секрет, то нефиг провайдеру его обрабатывать
if (!key.EndsWith(EndCommand, StringComparison.OrdinalIgnoreCase))
return;
_source.Secrets[key] = Parse(key, value); ;
base.Set(key, value);
}
/// <summary>
/// Загружает секрет в конфигурацию
/// </summary>
/// <param name="secretConfig">Параметры секрета</param>
private void DownloadSecret(ReferenceVaultSecretConfig secretConfig)
{
IDictionary<string, object> secrets = _source
.Client
.GetSecretValuesAsync(secretConfig.SecretPath).Result;
Data[secretConfig.PropertyPath] = secrets[secretConfig.SecretKey].ToString();
}
/// <summary>
/// Парсит референсное свойство секрета Vault
/// </summary>
/// <param name="key">Ключ</param>
/// <param name="value">Значение</param>
/// <returns>Ссылочное свойство конфигурации ссылающееся на секрет Vault</returns>
private ReferenceVaultSecretConfig Parse(string key, string value)
{
string[] args = value.Split();
return new ReferenceVaultSecretConfig
{
PropertyPath = key.Substring(0, key.Length - EndCommand.Length),
SecretPath = args[0],
SecretKey = args[1],
};
}
}
/// <summary>
/// Источник для провайдера данных из корпоративного хранилища секретов Vault
/// </summary>
public class HashiCorpVaultConfigurationSource : IConfigurationSource
{
/// <inheritdoc cref="IHashiCorpVaultClient"/>
public IHashiCorpVaultClient Client { get; set; }
/// <summary>
/// Коллекция секретов, которые нужно загрузить в конфиг
/// </summary>
public IDictionary<string, ReferenceVaultSecretConfig> Secrets { get; set; }
/// <inheritdoc />
public IConfigurationProvider Build(IConfigurationBuilder builder)
{
return new HashiCorpVaultConfigurationProvider(this);
}
}
/// <summary>
/// Ссылочное свойство конфигурации, ссылающееся на секрет Vault
/// </summary>
public class ReferenceVaultSecretConfig
{
/// <summary>
/// Путь к секрету Vault
/// </summary>
public string SecretPath { get; set; }
/// <summary>
/// Ключ секрета Vault
/// </summary>
public string SecretKey { get; set; }
/// <summary>
/// Свойство, в которое будет записан секрет Vault
/// </summary>
public string PropertyPath { get; set; }
}
Если не вдаваться в детали, то работу нашего поставщика секретов можно разделить на 2 действия:
Парсинг. Когда устанавливается новое свойство, поставщик пытается определить является ли оно ссылочным, получить из него путь и ключ секрета, а также наименование свойства, в которое нужно будет положить секрет;
Загрузка секретов. Когда происходит построение конфигурации или его перезагрузка, получаем и устанавливаем секреты.
Теперь у нас есть поставщик секретов и нам нужно его подключить. Но перед этим необходимо обеспечить наличие настроек клиента к Vault на этапе конфигурации хоста для приложения. Для простоты подложим туда appsettings файл.
public static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.ConfigureHostConfiguration(builder =>
builder.AddJsonFile("appsettings.json", true, false))
.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());
}
Перейдем к настройке подключения нашего поставщика секретов в приложении. Для этого мы напишем 2 метода.
/// <summary>
/// Настраивает приложение на работу с корпоративным хранилищем секретов
/// </summary>
/// <param name="hostBuilder">Строитель хоста</param>
/// <param name="vaultSection">Наименование секции с конфигурацией <see cref="HashiCorpVaultConfig"/></param>
/// <returns><paramref name="hostBuilder"/></returns>
public static IHostBuilder UseHashiCorpVault(
this IHostBuilder hostBuilder,
string vaultSection = "Endpoints:HashiCorpVault")
{
// Ссылку оставляем здесь так как нам нужно обеспечить единый клиент на разных этапах сборки приложения
IHashiCorpVaultClient vaultClient = null;
var secrets = new Dictionary<string, ReferenceVaultSecretConfig>(StringComparer.OrdinalIgnoreCase);
return hostBuilder
.ConfigureHostConfiguration(builder =>
{
IConfigurationRoot configuration = builder.Build();
HashiCorpVaultConfig vaultConfig = configuration
.GetSection(vaultSection)
.Get<HashiCorpVaultConfig>();
IOptions<HashiCorpVaultConfig> options = Options.Create(vaultConfig);
vaultClient = new HashiCorpVaultClient(options);
builder.Sources.Clear();
builder
.AddConfiguration(configuration)
.Add(new HashiCorpVaultConfigurationSource
{
Client = vaultClient,
Secrets = secrets,
});
})
.ConfigureAppConfiguration((hostContext, builder) =>
{
// Загружаем все секреты запрошенные на этапе создания конфигурации хоста
if (hostContext.Configuration is IConfigurationRoot configurationRoot)
configurationRoot.ReloadHashiCorpVaultSecrets();
builder.Add(new HashiCorpVaultConfigurationSource
{
Client = vaultClient,
Secrets = secrets,
});
})
.ConfigureServices((hostContext, services) =>
{
// Загружаем все секреты запрошенные на этапе создания конфигурации приложения
if (hostContext.Configuration is IConfigurationRoot configurationRoot)
configurationRoot.ReloadHashiCorpVaultSecrets();
services
.AddVaultClient(hostContext.Configuration)
.Decorate<IHashiCorpVaultClient, HashiCorpVaultClientCacheDecorator>();
});
}
/// <summary>
/// Перезагружает секреты в конфигурации
/// </summary>
/// <param name="configuration">Конфигурация приложения</param>
public static IConfigurationRoot ReloadHashiCorpVaultSecrets(this IConfigurationRoot configuration)
{
foreach(IConfigurationProvider provider in configuration.Providers)
if (provider is HashiCorpVaultConfigurationProvider)
{
foreach (KeyValuePair<string, string> config in configuration.AsEnumerable())
provider.Set(config.Key, config.Value);
provider.Load();
}
return configuration;
}
Здесь остановлюсь более подробно. Построение классического .NET Core+ приложения можно разделить на 4 основные части.
Начнем с метода ConfigureHostConfiguration. Так как нам нужны настройки клиента HashiCorp Vault, первое, что мы делаем, это строим конфигурацию хоста и извлекаем из него настройки клиента. После этого мы очищаем список источников данных в строителе конфигурации хоста, чтобы приложение не использовало эти источники дважды из-за вызова метода построения конфигурации под капотом. Далее мы добавляем добытую конфигурацию в строитель, чтобы не потерять эти данные и добавляем написанный нами источник HashiCorpVaultConfigurationSource для секретов HashiCorp Vault.
Перейдем к методу ConfigureAppConfiguration. Первое, что мы делаем, это перезагружаем секреты для конфигурации хоста, чтобы загрузить все секреты по ссылочным свойствам, полученным на предыдущем этапе. Далее снова добавляем наш поставщик секретов. Здесь нужно уточнить, что когда будет происходить построение конфигурации приложения, в нее так же будет добавлена конфигурация хоста. Но она будет добавлена туда образом, аналогичным тому, что мы использовали на первом этапе. Т.е. в конфигурации приложения не будет поставщиков из конфигурации хоста. А нам нужно, чтобы там был наш поставщик секретов. Поэтому мы добавляем его еще раз.
В методе ConfigureServices мы просто перезагружаем секреты конфигурации приложения, также добавляем клиент к HashiCorp Vault как сервис в приложение.
Теперь мы можем подключить нашу интеграцию к приложению:
public static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.ConfigureHostConfiguration(builder =>
builder.AddJsonFile("appsettings.json", true, false))
.UseHashiCorpVault()
.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>());
}
Результат созданного решения для интеграции с Vault
После обсуждений нескольких вариантов, мы выбрали и реализовали решение. Оно подключается к приложению в одну строчку методом UseHashiCorpVault в файле Program.
Выбранное решение довольно удобное и простое в освоении. Нужно просто добавить в конфигурации ссылочное свойство на секрет.
{
"Endpoints": {
"ClientConfig": {
"Host": "тут http путь к сервису",
"Credentials": {
"Username": "example-login",
"Password-VaultSecret": "root-folder/tech-users/example-login password",
"Token-VaultSecret": "root-folder/tech-users/example-login token"
}
},
"HashiCorpVault": {
"Host": "тут http путь к Vault",
"MountPoint": "наименование корневой папки в Vault",
}
}
}
Реализовав решение, мы перевели на него более 30 наших приложений. Теперь мы можем уменьшить наш код, удалив предыдущий способ получения и установки секретов, а также забыть о необходимости генерации нового кода для новых секретов.
M1XGear
А как вы работаете с обновлением значений секретов если они обновились в Vault?
И если вы не используете обновление секретов, то почему не взяли готовое решение в виде External Secrets Operator?
GetcuReone Автор
Мы не рассматривали данный функционал. Я почитал о нем. Он бы нам не подошел по следующим причинам:
у нас есть приложения которые не работают в Kubernetes
у нас и без того большое кол-во приложений. Мы хотим минимизировать администрирование в наших системах. А эта затея приведет к её увеличению. а эффект от этого будет не большой
API этого инструмета хоть и простой, но явно сложенее чем то к чему мы пришли
Этот момент пока что в планах. Сейчас мы с этим не работаем. Пока что по старинке перезагружаем приложение, но хочется от этого уходить