Ротация секретов
Замена значения без простоя и без ручной работы: env-master сам меняет пароль в целевой системе, проверяет его и только после проверки показывает сервисам.
Ротация включается политикой на запись: одна запись — одна политика. Пока политики нет, значение живёт столько, сколько его туда положили.
Четыре фазы
create → set → test → finish
- create — получить новое значение (у postgres — сгенерировать пароль;
пользователи из
usernamesдолжны существовать в базе заранее); - set — применить его там, где оно должно действовать;
- test — убедиться, что оно работает;
- finish — перенести метку
current, чтобы потребители начали читать новое.
Прогон всегда идёт с начала. Фоновый воркер тикает каждые 30 секунд и захватывает политику арендой на 2 минуты, так что два прогона одной политики не пересекаются: ручной запуск берёт ту же аренду, что и воркер.
Возобновления прогона с фазы нет и не планируется: прерванный прогон никогда
не продолжается, следующий всегда начинается заново с create. Для postgres
возобновление ничего не экономило бы — его create не трогает базу, а
только генерирует пароль, поэтому «заново» стоит один лишний ALTER USER
тому же пользователю. Ротатору, у которого create создаёт что-то с
лимитом (например, ключи доступа с ограничением на пользователя),
возобновление придётся проектировать заново вместе с самим ротатором.
Что именно делает ротатор postgres
Единственный встроенный ротатор — postgres. По фазам, без обобщений:
| Фаза | Что происходит в вашей базе |
|---|---|
create | базу не трогает. Генерируется пароль pg_ + 24 случайных байта; для формы «строка подключения» читается запись с админским DSN, чтобы взять из неё хост, порт и базу |
set | подключение под админским DSN и один запрос: ALTER USER "app_b" WITH PASSWORD '…' |
test | подключение тем самым значением, которое получит сервис, и запрос SELECT 1 |
finish | ничего в базе. Внутри env-master метка current переносится на новое значение, а в конфиг политики записывается имя пользователя, ставшего активным |
Отсюда три следствия, которые экономят вечер отладки:
- Ротатор не создаёт пользователей. Роли из
usernamesвы заводите сами и сами выдаёте им гранты. Ротатор только меняет им пароль. - Админский DSN должен быть URL —
postgres://admin:pw@host:5432/db. Фазаtestподставляет в него ротируемого пользователя и новый пароль, а для этого разбирает его как адрес; форматhost=… user=…на этой фазе не подойдёт. - Ротируемый пользователь должен подключаться туда же, куда админ. Хост,
порт, база и параметры для
testберутся из админского DSN — меняются только имя и пароль. Если у роли нет праваCONNECTна эту базу, прогон упадёт наtest, хотя пароль уже сменён.
Суперпользователь для этого не обязателен: достаточно роли, которой в вашей
версии PostgreSQL позволено менять пароль именно этим пользователям — обычно
CREATEROLE с правом администрировать их.
Настройка: пять шагов
Продолжение быстрого старта: переменные BASE, TOKEN,
PROJECT и ORG — оттуда.
1. Заведите роли в PostgreSQL
CREATE ROLE app_a LOGIN PASSWORD 'temporary-a';
CREATE ROLE app_b LOGIN PASSWORD 'temporary-b';
GRANT CONNECT ON DATABASE billing TO app_a, app_b;
-- дальше — ваши гранты на схемы и таблицы, одинаковые для обеих ролей
Пароли здесь временные: первый же прогон заменит их своими.
2. Положите админский DSN отдельной записью
curl -s "$BASE/projects/$PROJECT/entries?env=production" \
-X PUT -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key":"DB_ADMIN_DSN","kind":"secret","visibility":"restricted",
"value":"postgres://rotator:pw@db.internal:5432/billing"}'
restricted — не перестраховка: ротатор читает эту запись независимо от
видимости, а людям она не нужна ни на одном экране. Держать админский DSN
masked значит показывать его каждому, кто вправе смотреть значения.
3. Заведите запись, которую будете ротировать
ENTRY=$(curl -s "$BASE/projects/$PROJECT/entries?env=production" \
-X PUT -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key":"DB_PASSWORD","kind":"secret","visibility":"masked","value":"s3cret"}' \
| jq -r .id)
Тип обязательно secret. У переменной (var) значение хранится открытым
текстом — сгенерированный пароль оказался бы виден всем, кто видит таблицу.
4. Включите политику
single-user в примере — не потому, что dual-credential не работает
(работает, см. «Стратегии»), а потому, что для первого прогона проще: одна
роль, одно значение, меньше движущихся частей.
POLICY=$(curl -s $BASE/entries/$ENTRY/rotation-policy \
-X PUT -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"rotator": "postgres",
"strategy": "single-user",
"intervalSeconds": 604800,
"config": {
"adminDsnEntryRef": "my-org/billing-api/production/DB_ADMIN_DSN",
"usernames": ["app_a"]
}
}' | jq -r .id)
adminDsnEntryRef — ссылка орг/проект/окружение/КЛЮЧ, а не значение: DSN
остаётся записью со своими правами и своим журналом доступа. Ссылка обязана
вести в ту же организацию, и на её проект у вас должна быть роль не ниже
member — иначе политику не сохранить.
5. Проверьте первым прогоном, не дожидаясь расписания
curl -s $BASE/rotation-policies/$POLICY/run-now \
-X POST -H "authorization: Bearer $TOKEN" | jq '.status, .phase, .error'
Ждать неделю, чтобы узнать, что в DSN опечатка, незачем. Что означают ошибки первого прогона — в разделе «Когда прогон падает» ниже.
Стратегии: что выбрать
single-user | dual-credential | |
|---|---|---|
Пользователей в usernames | один | два, разных |
| Что меняется за прогон | пароль этого пользователя | пароль неактивного пользователя |
| Что попадёт в запись | пароль (по умолчанию) | строка подключения целиком |
| Открытые соединения | продолжают работать | продолжают работать |
| Новые соединения со старым паролем | отклоняются, пока сервис не перечитает значение | продолжают работать под активным пользователем |
Разница только в новых подключениях. PostgreSQL проверяет пароль при
установке соединения, поэтому уже открытые соединения ALTER USER не рвёт —
ни в одной из стратегий. Промежуток, в котором новое подключение со старым
паролем не пройдёт, есть только у single-user, и длится он от фазы set до
момента, когда ваш сервис перечитает значение.
Что попадёт в запись: пароль или строка подключения
У dual-credential активная роль меняется каждый прогон, поэтому один пароль
сервису бесполезен: под каким именем подключаться, он не узнает. Такая
политика публикует строку подключения целиком:
postgres://app_b:pg_xxxxxxxx@db.internal:5432/billing
Имя и пароль приезжают вместе, одной версией одной записи — то есть атомарно.
Хост, порт, база и параметры берутся из админского DSN: ротируемая роль ходит
туда же, куда админ. Записи с такой политикой стоит и называть
соответственно — DATABASE_URL, а не DB_PASSWORD.
У single-user имя не меняется никогда, поэтому по умолчанию публикуется
только пароль. Если сервису удобнее готовый URL, форма даёт выбрать
строку подключения и там — в конфиге это поле valueFormat:
valueFormat | Что в записи |
|---|---|
не задано, dual-credential с двумя ролями | строка подключения |
| не задано, остальные случаи | пароль |
"password" | пароль |
"dsn" | строка подключения |
Фаза test подключается ровно тем значением, которое получит сервис: собирать
его заново значило бы проверить не то, что уедет потребителю.
Два одинаковых имени в usernames сервер не отклоняет при сохранении —
прогон упадёт на фазе set с сообщением cannot determine the inactive username.
Что происходит с сервисами во время прогона
Пока прогон не дошёл до finish, сервисы получают прежнее значение:
машине выдаётся версия под меткой current, а её переносит только finish.
Незавершённый прогон в ленте — норма, а не поломка.
После finish новое значение подхватывается не мгновенно:
- SDK держит записи в кэше 15 минут (
cacheTtlMs), поэтому обычная задержка — до пятнадцати минут; refresh()сбрасывает кэш и перечитывает немедленно — вызов по вебхукуentry.rotatedпревращает ротацию в событие для сервиса, а не в ожидание;- процесс, прочитавший значение один раз в переменную при старте, не узнает о смене никогда. Это довод не за ротацию, а за то, чтобы читать секрет через SDK.
Подробности — в документации SDK.
Метка pending
Между create и finish новое значение уже сохранено под меткой
pending. Это не деталь реализации: если процесс упадёт после смены
пароля в базе, но до записи в env-master, пароль будет потерян — а он уже
действует. Значение становится durable раньше, чем начинает применяться.
Потребители в этот момент по-прежнему читают current и ничего не
замечают.
Если прогон упал после create, новое значение так и остаётся под меткой
pending — оно уже существует в целевой системе, и терять его нельзя.
Следующий прогон создаёт своё значение и записывает его следующей версией;
старое pending не применяется никогда. Такая версия остаётся в истории
записи как обычная версия, созданная rotation-engine, но никогда не
бывшая current.
Как читать прогон
У прогона есть статус и фаза — оба видны в ленте и в ответе
GET /rotation-runs.
| Статус | Что значит |
|---|---|
running | идёт фаза create, set или finish |
testing | идёт фаза test |
succeeded | прогон завершён, сервисы получают новое значение |
failed | прерван на фазе phase; см. оговорку ниже |
failed почти всегда означает «значение осталось прежним»: метка current
переносится в самом конце, после успешного test. Единственное исключение —
сбой внутри фазы finish уже после переноса метки: тогда прогон помечен
failed, а новое значение сервисы уже получают. Поэтому «ротация не удалась»
в ленте не читается как «секрет не менялся» — фаза в записи прогона говорит
точнее статуса.
Статусы pending и rolled-back зарезервированы в типе, но движок их
сейчас не выставляет.
Прогон, у которого упал процесс, не остаётся running навсегда: запуск
следующего прогона той же политики закрывает прежний незавершённый как
failed с причиной «прерван и вытеснен более новым прогоном». Порога по
времени нет намеренно — он угадывал бы, сколько ротация «должна» идти, а это
свойство целевой системы. До следующего запуска такой прогон в ленте кабинета
помечается зависшим по возрасту.
Когда прогон падает
Наружу движок отдаёт только собственные сообщения. Ошибку драйвера СУБД он
заменяет на «Rotation failed — see server logs for details»: в ней может
оказаться строка подключения целиком. Подробности — в серверном логе, запись
rotation run failed с policyId и runId.
| Сообщение | Что случилось |
|---|---|
admin DSN is not a valid connection string | значение записи по adminDsnEntryRef не разбирается как адрес подключения. На фазе test нужен именно URL-формат |
could not set the password for "X" on the target database | не удалось выполнить ALTER USER: роли нет, нет прав у админской роли, база недоступна |
the rotated credential for "X" was refused by the target database | пароль сменён, но зайти под ним нельзя. Обычно — нет права CONNECT на базу из админского DSN или роль не LOGIN |
cannot determine the inactive username | в usernames два одинаковых имени |
config.usernames must contain 1 or 2 usernames | пустой список или больше двух имён |
invalid username in config.usernames | имя роли не подходит под [A-Za-z_][A-Za-z0-9_]* |
Unknown rotator: X | ротатора с таким именем нет; встроенный один — postgres |
Pending value disappeared before finish | запись правили посреди прогона, и pending-версии не стало. Значение не опубликовано |
Rotation run was interrupted and superseded by a newer run | прогон не падал: его процесс умер, а закрыл его следующий запуск |
Secret reference is not accessible: … | ответ на сохранение политики: ссылка ведёт в другую организацию или в проект, где у вас нет роли member |
Неудачный прогон поднимает событие rotation.failed, вебхук и метрику.
Значение при этом остаётся прежним — потребители не замечают сбоя, пока
старое значение действует.
Политика
Полная форма запроса:
curl -s $BASE/entries/$ENTRY/rotation-policy \
-X PUT -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"rotator": "postgres",
"strategy": "dual-credential",
"intervalSeconds": 604800,
"config": {
"adminDsnEntryRef": "my-org/billing-api/production/DB_ADMIN_DSN",
"usernames": ["app_a", "app_b"]
}
}'
strategy—dual-credentialилиsingle-user;intervalSeconds— не меньше 60;adminDsnEntryRef— ссылкаорг/проект/окружение/КЛЮЧна запись с DSN администратора; ротатор читает её значение, сам DSN в конфиге не хранится;enabled— необязательный, по умолчаниюtrue;valueFormat— необязательный:"password"или"dsn". Умолчание —"dsn"дляdual-credentialс двумя ролями и"password"во всех остальных случаях;activeUsernameв конфиг добавляет сам ротатор — руками его не задавайте.
PUT заменяет config целиком. При правке существующей политики сперва
прочитайте её GET и перешлите activeUsername обратно — иначе ротатор
начнёт отсчёт с первого имени в списке и сменит пароль пользователю, под
которым сервисы сейчас работают.
Приостановить ротацию, не удаляя политику, — enabled: false. Снять
совсем — DELETE /entries/:id/rotation-policy; значение и история версий
остаются, меняться по расписанию оно перестаёт.
Права: настраивает, приостанавливает и запускает проектный admin, смотрит —
viewer. Роль в организации сама по себе доступа к ротации не даёт: через
неё меняется значение секрета, а это плоскость данных.
Ручной запуск
Внеочередной прогон — POST /rotation-policies/:id/run-now, история —
GET /rotation-runs?policyId=....
Ручной запуск, пока идёт прогон той же политики, отклоняется с
409 CONFLICT — тик и run-now делят одну аренду политики. Приостановленную
(enabled: false) политику run-now всё равно выполняет: это явная воля, а
не расписание. Каждый ручной запуск пишется в журнал событием
rotation.run.requested с именем запустившего.
Сводка по всей организации — GET /orgs/:id/rotations: последние прогоны
(до 5 на политику, всего до 50) по проектам, где у вас есть роль; владелец и
администратор организации видят все проекты. Та же лента — в кабинете,
раздел «Ротации»: состояние каждого прогона, фаза, на которой он упал, и
текст ошибки.
Управлять политиками из кабинета пока нельзя — только запросом
PUT /entries/:id/rotation-policy, показанным выше.
Откат
Одно действие (POST /entries/:id/rollback, право — member): прежнее
значение публикуется новой версией. История остаётся монотонной,
повторный откат идёт вперёд, а не качает значение туда-обратно. Если
предыдущей версии нет — 409 CONFLICT.
Откат меняет значение в env-master, но не в целевой системе: пароль в базе остаётся тем, который туда записала ротация. Откат ротируемой записи имеет смысл, только если вы вернёте пароль и в базе.
Какие ротаторы есть
Ротатор ровно один — postgres, обе стратегии (dual-credential и
single-user). MySQL, Redis и Mongo встречаются в комментариях к коду как
примеры точек расширения — встроенных ротаторов для них нет: политика с
rotator: "mysql" отклоняется с ошибкой Unknown rotator. Каркас движка не
завязан на конкретную СУБД: добавить ротатор значит реализовать четыре
метода фаз.