Key Vault: секреты и ключи

Пароль от базы не должен лежать ни в коде, ни в переменной окружения. Разбираемся, где ему место и как приложение достаёт его вообще без пароля.

Azure Key Vault — сейф для секретов, ключей шифрования и TLS-сертификатов. Приложение не хранит пароли у себя: оно предъявляет своё удостоверение и получает нужный секрет по запросу, а каждое обращение попадает в журнал.

Почему коду не место рядом с паролем

«Да я потом вынесу в конфиг» — фраза, после которой строка подключения к продовой базе живёт в репозитории годами. Проблема даже не в том, что кто-то заглянет в исходники. Проблема в том, что у секрета в коде нет ни одного из свойств, которые нужны секрету:

  • Его нельзя отозвать. Git помнит всё: удалили строку в новом коммите — она осталась в истории и в форках.
  • Его нельзя сменить. Ротация пароля превращается в релиз с пересборкой образа.
  • Никто не знает, кто им пользовался. Аудита нет. После инцидента вы не ответите на вопрос «кто и когда читал».
  • Он расползается. Один и тот же пароль оказывается в CI, в Docker-образе, в тикете, в скриншоте.

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

Что умеет Key Vault

Тип объектаЧто этоПример
Secretпроизвольная строка до 25 КБстрока подключения, API-токен, пароль SMTP
Keyкриптографический ключ, который не покидает хранилищеключ шифрования дисков, подпись JWT
CertificateTLS-сертификат с автообновлениемсертификат для домена в App Service

Разница между секретом и ключом принципиальна. Секрет вы забираете — Key Vault отдаёт вам строку. Ключ вы не забираете никогда: вы отправляете данные в Key Vault и просите зашифровать или подписать их, а приватная часть ключа физически не покидает хранилище (в Premium — вообще живёт в HSM, аппаратном модуле). Поэтому пароль от базы — это secret, а ключ, которым вы подписываете токены, — это key.

# хранилище сразу в режиме RBAC — так и надо для новых проектов
az keyvault create \
  --resource-group rg-shop \
  --name kv-shop-prod \
  --location westeurope \
  --enable-rbac-authorization true

az keyvault secret set \
  --vault-name kv-shop-prod \
  --name db-connection \
  --value 'postgresql://app:S3cure!@pg-shop.postgres.database.azure.com/shop'

az keyvault secret show --vault-name kv-shop-prod --name db-connection --query value -o tsv

Флаг --enable-rbac-authorization true важен. У Key Vault исторически две модели доступа: старые access policies (отдельный список прав внутри самого хранилища) и обычный Azure RBAC. Access policies — легаси: они не наследуются, не видны в общем аудите ролей и не поддерживают Deny. Берите RBAC.

Как приложение получает секрет без пароля

Схема ровно та, о которой шла речь в уроке про RBAC: у приложения есть managed identity, ему выдаётся роль Key Vault Secrets User на конкретное хранилище — и всё.

PRINCIPAL_ID=$(az webapp identity assign --resource-group rg-shop --name app-shop-api --query principalId -o tsv)
VAULT_ID=$(az keyvault show --name kv-shop-prod --query id -o tsv)

az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Key Vault Secrets User" \
  --scope "$VAULT_ID"

Код приложения при этом не содержит ни логина, ни пароля, ни ключа: DefaultAzureCredential сам разберётся, где он выполняется. На вашем ноутбуке он возьмёт токен из az login, в App Service — сходит к эндпоинту managed identity. Один и тот же код работает и локально, и в проде.

from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

credential = DefaultAzureCredential()
client = SecretClient(
    vault_url="https://kv-shop-prod.vault.azure.net/",
    credential=credential,
)

secret = client.get_secret("db-connection")
print("Версия секрета:", secret.properties.version)
# secret.value передаём в драйвер БД и никуда не логируем

Если приложение живёт в App Service или Azure Functions, можно вообще обойтись без SDK — достаточно ссылки на Key Vault в настройках приложения. Платформа сама сходит в хранилище от имени managed identity и подставит значение в переменную окружения:

DB_CONNECTION=@Microsoft.KeyVault(SecretUri=https://kv-shop-prod.vault.azure.net/secrets/db-connection/)

Как это работает

Под капотом — уже знакомая цепочка. Приложение просит токен у локального эндпоинта удостоверения, Entra ID выдаёт JWT для audience https://vault.azure.net, приложение идёт с этим токеном на data plane Key Vault, Key Vault проверяет подпись и спрашивает RBAC, есть ли у принципала право .../secrets/getSecret/action на это хранилище. Паролей в этой цепочке нет ни на одном шаге.

Есть два свойства Key Vault, о которых надо знать заранее, потому что они ведут себя не так, как ожидают новички.

Версии секретов

Секрет не перезаписывается — каждый secret set создаёт новую версию, старые остаются доступными по полному URI с версией. Это спасает при откате, но и создаёт ловушку: если вы вписали в конфиг ссылку с явной версией, после ротации приложение продолжит читать старый пароль. Ссылайтесь на секрет без версии, тогда всегда придёт актуальная.

Soft-delete и purge protection

Удалённое хранилище не исчезает: оно уходит в «корзину» на 90 дней (soft-delete включён принудительно и выключить его нельзя). Имя всё это время остаётся занятым. Если вы в учебном проекте удалили kv-shop-prod и пытаетесь создать его заново — получите ошибку «имя уже используется». Лечится так:

az keyvault list-deleted -o table
az keyvault purge --name kv-shop-prod --location westeurope

А вот если при создании была включена purge protection, очистить хранилище досрочно нельзя вообще никак — ни вам, ни поддержке. Имя будет мёртвым 90 дней. В проде это защита от злоумышленника, в песочнице — грабли: не включайте purge protection на учебных ресурсах.

Ротация

Ротация — это регулярная смена секрета, чтобы утёкший однажды пароль перестал работать. Key Vault даёт для неё три инструмента:

  • Срок жизни. У секрета есть атрибуты expires и notBefore. Просроченный секрет SDK читать откажется — это заставляет процесс ротации существовать, а не «планироваться».
  • События. Key Vault публикует в Event Grid события SecretNearExpiry и SecretExpired. На них вешают Azure Function, которая генерирует новый пароль, прописывает его в базу и кладёт новую версию в хранилище.
  • Автоматическая ротация ключей. Для объектов типа key настраивается политика, и Azure сам создаёт новую версию по расписанию.
az keyvault secret set-attributes \
  --vault-name kv-shop-prod \
  --name db-connection \
  --expires '2026-12-31T00:00:00Z'

Приложение при ротации не должно падать. Практический приём — кэшировать секрет в памяти на несколько минут и перечитывать по истечении TTL (а при ошибке аутентификации — принудительно). Логику кэша легко проверить на модели с «часами» вместо реального времени:

TTL = 300  # секунд
cache = {}
calls = 0

def fetch_from_vault(name):
    global calls
    calls += 1
    return "s3cr3t-" + name

def get_secret(name, now):
    entry = cache.get(name)
    if entry and now - entry[1] < TTL:
        return entry[0]
    value = fetch_from_vault(name)
    cache[name] = (value, now)
    return value

for t in (0, 100, 200, 400, 500):
    print(t, get_secret("db-connection", t))

print("Обращений к Key Vault:", calls)

Результат:

0 s3cr3t-db-connection
100 s3cr3t-db-connection
200 s3cr3t-db-connection
400 s3cr3t-db-connection
500 s3cr3t-db-connection
Обращений к Key Vault: 2

Пять обращений приложения превратились в два похода в хранилище. На нагруженном сервисе это разница между работой и упором в лимит.

Частые ошибки

  • Чтение секрета на каждый HTTP-запрос. У Key Vault есть троттлинг (порядка 2000 операций с секретами за 10 секунд на хранилище). Превысили — получаете 429 и лежачий сервис. Кэшируйте.
  • Секрет попал в лог. Достали значение и залогировали объект целиком «для отладки» — теперь пароль в Log Analytics, доступном половине команды. Никогда не логируйте secret.value.
  • Contributor на Key Vault считают безопасным. В RBAC-режиме Contributor действительно не читает секреты напрямую — но он может выдать себе роль Secrets User или переключить хранилище на access policies. Не раздавайте Contributor на хранилище с продовыми секретами.
  • Ссылка на секрет с явной версией. После ротации приложение молча продолжает жить со старым паролем — пока старая версия не истечёт и всё не сломается в самый неподходящий момент.
  • Хранилище открыто всему интернету. Аутентификация есть, но публичная сетевая доступность лишняя: включите firewall и Private Endpoint (порядка 7–8 $ в месяц), чтобы хранилище отвечало только из вашей VNet.
  • Деньги. Само хранилище бесплатно, операции с секретами стоят копейки (порядка 0,03 $ за 10 000 операций). Но Managed HSM — это выделенный кластер аппаратных модулей с оплатой по часам, счёт легко переваливает за 2000 $ в месяц. Не включайте его «посмотреть, что это»: для обычных секретов достаточно Key Vault уровня Standard.

Итоги

  • Секрет в коде нельзя отозвать, сменить и проверить по журналу — переменные окружения проблему не решают.
  • Key Vault хранит секреты (забираем), ключи (не покидают хранилище) и сертификаты; создавайте его с --enable-rbac-authorization true.
  • Приложение получает секрет через managed identity и роль Key Vault Secrets User — паролей в цепочке нет вообще.
  • Каждая запись создаёт новую версию; ссылайтесь на секрет без версии, иначе ротация пройдёт мимо приложения.
  • Кэшируйте секрет в памяти: троттлинг Key Vault реален.
  • Soft-delete держит имя 90 дней, purge protection делает досрочную очистку невозможной — на учебных стендах её не включают.
Проверьте себя
1. Приложение в App Service читает пароль из Key Vault. Что нужно, чтобы в его коде не было ни пароля, ни ключа доступа?
AПоложить строку подключения к Key Vault в переменную окружения
BВключить managed identity и выдать ей роль Key Vault Secrets User на хранилище
CСоздать service principal и хранить его секрет в appsettings
DОткрыть Key Vault для публичного сетевого доступа
2. Вы удалили Key Vault с именем kv-shop-prod и сразу пытаетесь создать новый с тем же именем. Azure отвечает, что имя занято. В чём причина?
AИмена хранилищ уникальны только внутри ресурсной группы, надо сменить группу
BУдаление занимает несколько часов, нужно просто подождать
CРаботает soft-delete: хранилище лежит в корзине 90 дней и держит имя, пока его не сделать purge
DИмя навсегда закреплено за подпиской и повторно не используется