Terraform-модуль для прав доступа в Vault: политики и роли (jwt/oidc, kubernetes, approle). Значения секретов в KV модуль не трогает — только права на них.
Провайдер модуль не конфигурирует: адрес, CA и способ логина задаёт корневой конфиг. Один вызов модуля = один Vault.
Требования: Terraform >= 1.12, провайдер hashicorp/vault ~> 4.4.
Граница именно 1.12, а не 1.5 как в 1.x: до неё Terraform вычислял оба операнда ||, и проверка входов вида m.kv_version == null || contains([1, 2], m.kv_version) падала на самом обычном случае — маунте без явно заданной версии. Проверено прогоном тестов: 1.9, 1.10 и 1.11 валятся, 1.12.0 проходит.
Обычный случай — сервису нужны свои пути и свой вход. Одна запись в services создаёт политику и роль под одним именем, уже связанные между собой:
module "access" {
source = "git::https://github.com/AndreyZa/terraform-module-vault.git?ref=v3.0.0"
kv_mount = "secret" # обязателен, дефолта нет
kv_version = 2 # 1 или 2, должно совпадать с маунтом
services = {
"billing" = {
# что можно
read_paths = ["apps/billing/prod", "apps/billing/common"]
list_paths = ["apps/billing"]
# кому можно — роль ляжет в auth/jwt/role/billing
bound_audiences = ["https://vault.example.internal"]
bound_claims = {
project_path = "platform/billing"
ref = "main"
}
}
}
}Добавить сервис — одна запись; имя политики нигде не повторяется, разъехаться нечему.
Раздельные policies и jwt_roles остаются для остального: политика без роли, роль на несколько политик, роль на чужую политику. Там же живут kubernetes и approle:
policies = {
"billing-prod-ro" = { read_paths = ["apps/billing/prod"] }
}
clusters = {
"prod-a" = {
host = "https://api.prod-a.example.internal:6443"
ca_cert = var.cluster_ca_certs["prod-a"]
}
}
k8s_roles = {
"prod-a" = {
"billing" = {
namespaces = ["billing"]
service_accounts = ["billing"]
policies = ["billing-prod-ro"]
}
}
}| Переменная | Тип | По умолчанию | Назначение |
|---|---|---|---|
kv_mount |
string |
— обязателен | KV-маунт, на который выписываются права |
kv_version |
number |
1 |
версия KV-движка: 1 или 2 |
manage_kv_mount |
bool |
false |
создавать ли сам маунт (обычно он уже есть) |
kv_description |
string |
null |
описание маунта, если модуль его создаёт |
services |
map(object) |
{} |
одна запись = политика + JWT-роль под тем же именем |
policies |
map(object) |
{} |
политики из путей: read_paths / write_paths / list_paths / allow_destroy / path_capabilities / raw_path_capabilities / mounts / extra_rules |
policy_files_dir |
string |
"policies" |
каталог с рукописными *.hcl, путь от корневого конфига |
raw_policies |
map(string) |
{} |
политики готовым HCL, если он собирается у вызывающего |
external_policies |
list(string) |
[] |
чужие политики, на которые ролям разрешено ссылаться |
clusters |
map(object) |
{} |
кластеры: host, ca_cert, auth_path, disable_local_ca_jwt, TTL маунта |
k8s_roles |
map(map(object)) |
{} |
{ кластер = { роль = { namespaces, service_accounts, policies, audience, … } } } |
default_token_ttl |
number |
600 |
TTL токена (10 мин) |
default_token_max_ttl |
number |
900 |
предел продления (15 мин) |
default_token_explicit_max_ttl |
number |
900 |
жёсткий предел жизни токена (15 мин) |
default_token_bound_cidrs |
list(string) |
["127.0.0.0/8", "10.0.0.0/8"] |
откуда токен принимается |
default_bound_audiences |
list(string) |
[] |
аудитория токена — обычно адрес самого Vault |
jwt_path |
string |
"jwt" |
путь JWT/OIDC-бэкенда; роли лягут в auth/<jwt_path>/role/<имя> |
manage_jwt_backend |
bool |
false |
управлять ли конфигом самого метода (issuer, ключи) |
jwt_backend |
object |
{} |
конфиг метода — только при manage_jwt_backend = true |
jwt_roles |
map(object) |
{} |
роли JWT/OIDC: policies, user_claim, bound_audiences, bound_claims, … |
approle_path |
string |
"approle" |
путь AppRole-бэкенда |
manage_approle_backend |
bool |
true |
создавать ли сам AppRole-бэкенд |
approle_roles |
map(object) |
{} |
роли AppRole: policies, TTL, token_period, ограничения по CIDR |
Политики приходят из четырёх источников — services, policies, policy_files_dir, raw_policies — и складываются. Одно имя в двух источниках валит plan: merge() оставил бы только последний, а остальные определения потерялись бы молча.
policy_files_dir по умолчанию "policies", то есть модуль сам читает <корневой конфиг>/policies/*.hcl, ничего не спрашивая. Отсутствие каталога безопасно — файловых политик просто не будет. Но если каталог с таким именем у вас уже есть под что-то постороннее, его файлы молча станут политиками Vault. Не пользуетесь файловыми политиками — поставьте policy_files_dir = null явно, как во всех примерах. Дефолт оставлен прежним намеренно: смена его на null тихо удалила бы политики у тех, кто на неявный подхват полагается, а пропавшая политика хуже лишней.
policies, kubernetes_auth_paths, kubernetes_roles, jwt_login_path, jwt_roles, approle_login_path, approle_role_ids.
Если роли уже живут в auth/jwt/role, бэкенд трогать не нужно — модуль по умолчанию только кладёт под него роли:
module "access" {
source = "git::https://github.com/AndreyZa/terraform-module-vault.git?ref=v3.0.0"
kv_mount = "secret"
kv_version = 1
policies = {
"billing-ro" = { read_paths = ["apps/billing/prod"] }
}
jwt_path = "jwt" # где уже стоит метод
jwt_roles = {
"billing-ci" = {
policies = ["billing-ro"]
user_claim = "sub" # что попадёт в аудит
bound_audiences = ["https://vault.example.internal"] # кому выписан токен
bound_claims = { # что ещё должно совпасть
project_path = "platform/billing"
ref = "main"
}
}
}
}Роль обязана быть чем-то ограничена — bound_audiences, bound_subject или bound_claims. Иначе токен получит любой предъявитель валидного JWT от этого issuer'а; модуль валит такой plan.
Для шаблонов в значениях (ветки, окружения, репозитории) — bound_claims_type = "glob", тогда работает ref = "release/*". Режим общий для всей карты, не для отдельного claim'а.
Роли всегда ложатся в auth/<jwt_path>/role/<имя>. Если метод поднят не на jwt, а, скажем, на jwt_v2:
jwt_path = "jwt_v2" # → auth/jwt_v2/role/test-terraform-ro, логин: auth/jwt_v2/loginОдин вызов модуля работает с одним путём. Нужны роли сразу в нескольких (jwt и jwt_v2) — два вызова модуля с разными jwt_path.
aud в токене — это адрес того Vault, для которого issuer его выписал. Он один на всю установку, поэтому задаётся один раз, а роли наследуют:
default_bound_audiences = ["https://vault.example.internal"]
services = {
"billing" = {
read_paths = ["apps/billing/prod"]
bound_claims = { project_id = "42" }
# bound_audiences не пишем — берётся общий
}
}Модуль не может подставить адрес сам: он живёт в конфигурации провайдера, а её модуль не видит.
Роль может задать свой список или отказаться от проверки — bound_audiences = []; тогда ограничением обязаны служить bound_claims или bound_subject, иначе plan не пройдёт.
| значение | что делает | |
|---|---|---|
token_ttl |
10 мин | сколько живёт свежий токен |
token_max_ttl |
15 мин | докуда его можно продлевать |
token_explicit_max_ttl |
15 мин | жёсткий потолок: по истечении токен отзывается, продление не спасает |
token_bound_cidrs |
127.0.0.0/8, 10.0.0.0/8 |
откуда токен вообще принимается |
user_claim |
project_id |
что становится именем сущности в аудите |
Меняются глобально через default_token_* / default_token_bound_cidrs либо точечно в роли. Снять ограничение по сети у одной роли — token_bound_cidrs = [] (пустой список, не null: null означает «взять общий»).
Порядок в token_bound_cidrs ни на что не влияет: провайдер отдаёт список множеством, и Vault хранит его в своём порядке независимо от того, как написано в конфиге. Diff от перестановки не возникает.
Токен, выданный ролью, ограничен её же настройками — «продлить подольше» на стороне клиента нельзя. Долгая жизнь бывает двух видов.
Периодический — живёт бесконечно, пока его продлевают не реже token_period. Дефолтные потолки нужно снять, иначе они его прижмут:
jwt_roles = {
"gitlab-role-terraform-periodic" = {
policies = ["ro-stg-gitlab-terraform"]
bound_claims = { project_id = "42" }
token_period = 86400 # сутки
token_ttl = 0 # иначе TTL возьмётся из дефолта
token_max_ttl = 0 # иначе прижмёт до 15 минут
token_explicit_max_ttl = 0 # иначе отзовёт, сколько ни продлевай
}
}token_period вместе с ненулевым token_max_ttl или token_explicit_max_ttl валит plan: любой из потолков сильнее продления — на живом Vault роль с period = 86400 и token_max_ttl = 900 выдаёт токен с ttl = 899, и renew его не поднимает. token_period = 0 — «не периодический», такой конфиг легален и ничего не требует.
Просто длинный — конечный срок, дальше перелогин:
token_ttl = 604800 # 7 дней
token_max_ttl = 2592000 # 30 дней
token_explicit_max_ttl = 0Обе формы всё равно требуют логина по JWT, то есть валидного токена от issuer'а. Если нужен статичный токен, не привязанный к пайплайну, — его выдают не ролью, а напрямую под политику:
vault token create -policy=ro-stg-gitlab-terraform -period=768h -display-name=terraform-staticТакой токен придётся хранить и вовремя отзывать (vault token revoke), поэтому для CI обычно оставляют короткий логин на каждый прогон.
token_ttl больше token_explicit_max_ttl валит plan: иначе токен отзывался бы раньше, чем истекал его собственный TTL.
bound_claims — это map(string); несколько допустимых значений перечисляются через запятую и работают как ИЛИ:
bound_claims = {
project_id = "42" # Vault покажет map[project_id:[42]]
ref = "main"
ref_protected = "true"
}bound_claims = {
project_id = "42,57,91" # Vault покажет map[project_id:[42 57 91]]
}Тип значения в токене должен совпадать. Сравнение не приводит число к строке: если issuer положил "project_id": 42 числом, а в политике написано "42", логин отвалится с claim "project_id" does not match any associated bound claim values. GitLab отдаёт project_id строкой, но проверять стоит по факту:
cut -d. -f2 <<< "$TOKEN" | base64 -d 2>/dev/null | python3 -m json.toolВложенный claim адресуется через /: bound_claims = { "metadata/team" = "platform" }.
Конфиг самого метода (issuer, ключи, discovery) можно забрать под Terraform: manage_jwt_backend = true + jwt_backend = {...} и terraform import module.<имя>.vault_jwt_auth_backend.this[0] jwt. По умолчанию выключено намеренно: роли меняются часто, а конфиг метода заводят один раз, и перетереть его чужим apply — уронить логин всем сразу.
Версия движка меняет не синтаксис политики, а пространство путей, поэтому одна и та же запись read_paths = ["apps/demo/prod"] при kv_mount = "secret" разворачивается по-разному:
KV v1 (kv_version = 1, по умолчанию) |
KV v2 (kv_version = 2) |
|
|---|---|---|
| чтение | secret/apps/demo/prod → read |
secret/data/… → read, secret/metadata/… → read, list |
| запись | … → create, read, update, list |
data/… → create, read, update, patch, delete; metadata/… → read, list; delete/… и undelete/… → update |
| обход дерева | … → list |
metadata/… → list |
allow_destroy = true |
добавляет delete (в v1 оно сразу безвозвратное) |
добавляет destroy/… → update и create, update, delete по metadata/… |
kv_version обязан совпадать с реальной версией маунта. Политика, выписанная не под ту версию, синтаксически верна, применяется без единой ошибки и не даёт никаких прав — это самая частая причина «политика есть, а 403». Проверить: vault read sys/mounts/<kv_mount> → options.version (у v1 поле обычно пустое).
Мягкого удаления в v1 нет, поэтому write_paths там не выдаёт delete — только с явным allow_destroy = true. В v2 write_paths даёт мягкое удаление во всех трёх его формах, а безвозвратные destroy/ и снос metadata — лишь по allow_destroy.
Форм мягкого удаления в v2 действительно три, и права им нужны разные:
| команда | эндпоинт | нужное право |
|---|---|---|
vault kv delete <path> |
DELETE data/<path> |
delete на data/ |
vault kv delete -versions=N <path> |
POST delete/<path> |
update на delete/ |
vault kv undelete -versions=N <path> |
POST undelete/<path> |
update на undelete/ |
Первая строка — самая обычная команда, и именно её легко упустить: право на delete/<path> её не покрывает, потому что CLI без -versions бьёт в data/. Все три write_paths выдаёт разом. delete на data/ при этом остаётся мягким: секрет скрывается, undelete возвращает прежнее значение, destroy без allow_destroy по-прежнему запрещён.
Запись в metadata/ писателю не выдаётся — только чтение и list. Причина проверена на живом Vault: create/update на metadata/ позволяют vault kv metadata put -max-versions=1, после чего следующая запись безвозвратно вытесняет всю историю секрета — то есть обходят allow_destroy = false целиком. Кому нужно писать custom-metadata — выдайте точечно:
raw_path_capabilities = {
"secret/metadata/apps/demo" = ["update"]
}С allow_destroy = true запись в metadata/ есть: там стирание уже разрешено явно.
Верхнеуровневые *_paths относятся к kv_mount. Пути с других маунтов — включая другую версию KV — добавляются через mounts:
policies = {
"ro-stg-gitlab-terraform" = {
read_paths = ["k8s/test"] # platform-infra, kv2
mounts = [
{
mount = "legacy"
kv_version = 1
read_paths = ["old/app"] # legacy, kv1
}
]
}
}Раскрывается в одну политику, где каждый маунт разложен по своим правилам:
path "platform-infra/data/k8s/test" { capabilities = ["read"] }
path "platform-infra/metadata/k8s/test" { capabilities = ["read", "list"] }
path "legacy/old/app" { capabilities = ["read"] }kv_version внутри mounts необязателен — без него берётся общий.
read_paths / write_paths — готовые наборы. Когда нужен именно свой (например, писать, но не читать), есть path_capabilities: путь → capabilities как есть.
policies = {
"drop-box" = {
path_capabilities = {
"k8s/writeonly" = ["create", "update"]
}
}
}Права ложатся на путь данных (<mount>/<путь> в v1, <mount>/data/<путь> в v2) и складываются с остальными правилами на тот же путь. Внутри mounts поле тоже доступно. Допустимые значения — create, read, update, patch, delete, list, sudo, deny; опечатка валит plan.
Метаданные (metadata/, delete/, destroy/) этим полем не затрагиваются — если нужны и они, добавьте путь в write_paths или опишите строфу в extra_rules.
auth/, sys/, transit/ и прочее живут не в KV-маунте, поэтому для них отдельное поле — raw_path_capabilities: путь берётся как есть, без префикса и без оглядки на версию.
policies = {
"ro-stg-gitlab-terraform" = {
read_paths = ["k8s/test"]
raw_path_capabilities = {
"auth/token/create" = ["create", "update"]
}
}
}Правила складываются с остальными по тому же слиянию путей.
Заметьте: встроенная политика default уже даёт auth/token/lookup-self, renew-self, revoke-self и sys/capabilities-self, а её получает каждый токен. Выписывать их отдельно нужно только при token_no_default_policy = true на роли.
auth/token/create — право выпускать дочерние токены; именно оно нужно провайдеру vault, когда у него не выставлен skip_child_token.
claim_mappings кладёт claim'ы из токена в метаданные, имена ключей — любые свои:
claim_mappings = {
project_path = "project" # claim → ключ метаданных
ref = "branch"
user_login = "actor"
ref_protected = "protected"
}После логина они видны и в токене, и в alias сущности:
actor: andrey branch: main project: platform/test-terraform protected: true
По ним можно строить путь прямо в политике — один шаблон вместо политики на каждый проект:
extra_rules = <<-EOT
path "platform-infra/data/{{identity.entity.aliases.auth_jwt_326da3fe.metadata.project}}/*" {
capabilities = ["read"]
}
EOTAccessor метода берётся из vault auth list -format=json. Значение со слэшем (platform/test-terraform) работает — путь просто становится глубже.
Шаблон Vault пишется в двойных фигурных скобках и доллара не содержит, поэтому экранировать в extra_rules нечего. $${...} — синтаксис Terraform; Vault его не понимает и молча не сматчит путь, политика при этом выглядит правдоподобно.
Пути из read_paths, write_paths и list_paths можно пересекать: правила складываются по путям, и на один путь всегда приходится ровно одна path-строфа с объединёнными capabilities в каноническом порядке. (Современный Vault и сам объединяет повторные строфы — проверено на 2.0.3, — но это поведение менялось между версиями; единая строфа не полагается на него и не «дрожит» в diff.)
Исключение — deny: он побеждает все остальные права строфы, поэтому путь, попавший и в *_paths, и в path_capabilities с deny, после слияния молча терял бы добавленные права. Такой конфликт валит plan. deny на отдельном пути легален — это другая строфа, и там он работает как задумано.
Всё, что не ложится в шаблон (sys/, transit/, шаблоны с {{identity.*}}), пишется файлом в policy_files_dir. Читается только верхний уровень каталога — policies/team/x.hcl молча не попадёт. Файлу через templatefile передаются mount, kv_version, data_prefix и metadata_prefix (""/"" для v1, "data/"/"metadata/" для v2), литеральный доллар экранируется удвоением. Права на KV лучше описывать через policies, а файлом — то, что от версии не зависит: файловые политики модуль не разбирает, и их пересечения с генерируемыми строфами никак не проверяются.
Всё перечисленное валит plan. Мягких предупреждений модуль не использует: check в Terraform не останавливает apply, и до 2.0 коллизии имён так и уезжали в прод.
validation — на входах:
- неизвестное право или пустой список прав в
path_capabilities/raw_path_capabilities/mounts[*].path_capabilities(и то и другое молча давало бы строфуcapabilities = [], которую Vault принимает и которая не даёт ничего). Допустимые права:create,read,update,patch,delete,list,subscribe,recover,sudo,deny; kv_versionне1/2— в том числе внутриmounts[*], где иное значение молча трактовалось бы как v1;role_typeнеjwt/oidc,bound_claims_typeнеstring/glob;kv_mountилиmounts[*].mountпустой, с ведущим/завершающим слэшем либо с//— Vault нормализует путь маунта, политика остаётся как написана и молча не матчит;- элементы
read_paths/write_paths/list_pathsи ключиpath_capabilities/raw_path_capabilitiesпустые, со слэшами по краям или с//— по той же причине; - пустые ключи карт (
policies,services,jwt_roles,raw_policies,approle_roles,clusters,k8s_roles) — plan проходил, apply падал посреди прогона с криптичным 405; - отрицательные TTL,
token_period,secret_id_ttl,secret_id_num_uses; - пустой
user_claim, пустые или со слэшами по краямjwt_path/approle_path; default_lease_ttl/max_lease_ttlкластера не Go-длительность ("1h","30m").
precondition — на ресурсах:
- роль ссылается на политику, которой нет в конфиге (Vault такую роль создаёт молча, логин проходит, прав нет) — описать политику либо внести имя в
external_policies; - JWT-роль ничем не ограничена (нет ни
bound_audiences, ниbound_subject, ниbound_claims); - JWT-роль с
role_type = "oidc"безallowed_redirect_uris; jwt_backendне задаёт ровно один способ проверки подписи (ноль способов — тоже ошибка);- роль привязана к кластеру, которого нет в
clusters; disable_local_ca_jwt = trueбезca_cert— Vault снаружи кластера, и TLS до API-сервера без CA не поднимется;bound_service_account_names = ["*"]вместе сnamespaces = ["*"];- имя политики не в нижнем регистре (Vault приводит к lowercase → вечный diff);
- политика без единой
path-строфы — хоть пустая, хоть из одних комментариев: Vault принимает такую молча, а прав она не даёт; denyсмешан с другими правами на одном пути — deny побеждает, добавленные права молча не работали бы;- одно имя политики из двух источников; одно имя роли и в
services, и вjwt_roles; token_ttlбольшеtoken_explicit_max_ttl— для всех трёх типов ролей;token_periodвместе с ненулевымtoken_max_ttlилиtoken_explicit_max_ttl— для всех трёх типов ролей. Прижимают оба: на живом Vault роль сperiod = 86400иtoken_max_ttl = 900выдаёт токен сttl = 899, иrenewего не поднимает.token_period = 0— «не периодический», это не ошибка.
Пересечение read_paths / write_paths / list_paths не запрещено: правила складываются по путям, на один путь всегда приходится ровно одна path-строфа с объединёнными capabilities.
Порядок гарантирован: роли зависят от vault_policy.this, поэтому в одном apply политика создаётся раньше роли.
Если политика и роль уже заведены руками, их нужно забрать в стейт — иначе apply создаст их заново поверх (политику перепишет, роль перетрёт).
Одна запись services — это два объекта, импортируются оба:
terraform import 'module.access.vault_policy.this["test-terraform-ro"]' test-terraform-ro
terraform import 'module.access.vault_jwt_auth_backend_role.this["test-terraform-ro"]' auth/jwt_v2/role/test-terraform-roИдентификаторы по типам:
| Ресурс | ID для импорта |
|---|---|
vault_policy.this["<имя>"] |
<имя> |
vault_jwt_auth_backend_role.this["<имя>"] |
auth/<jwt_path>/role/<имя> |
vault_jwt_auth_backend.this[0] |
<jwt_path> |
vault_auth_backend.approle[0] |
<approle_path> |
vault_approle_auth_backend_role.this["<имя>"] |
auth/<approle_path>/role/<имя> |
vault_auth_backend.kubernetes["<кластер>"] |
<auth_path> (по умолчанию kubernetes/<кластер>) |
vault_kubernetes_auth_backend_config.this["<кластер>"] |
auth/<auth_path>/config |
vault_kubernetes_auth_backend_role.this["<кластер>/<роль>"] |
auth/<auth_path>/role/<роль> |
vault_mount.kv[0] |
<kv_mount> |
module.<имя>. в адресе — имя вашего module блока.
После импорта сделайте terraform plan: он покажет, чем живой объект отличается от описанного. У политики почти всегда будет расхождение в шапке — модуль добавляет комментарий «сгенерирована Terraform», — а вот отличия в path или capabilities означают, что описание разошлось с реальностью, и стоит разобраться до apply.
Что уже есть в Vault: vault policy list, vault list auth/<jwt_path>/role, vault read auth/<jwt_path>/role/<имя>.
Переключить manage_kv_mount / manage_jwt_backend / manage_approle_backend в false — это не «перестать управлять»: Terraform планирует destroy, а уничтожение auth-бэкенда сносит все роли под ним и ломает логин всем сразу (проверено на живом Vault — apply бодро рапортовал «1 destroyed», роли исчезали, state о них ещё помнил). Поэтому на KV-маунте и обоих бэкендах стоит prevent_destroy: такой plan упирается в ошибку Instance cannot be destroyed вместо тихой катастрофы.
Правильный порядок — сначала забыть объект в state, потом выключить флаг:
terraform state rm 'module.<имя>.vault_mount.kv[0]' # для manage_kv_mount
terraform state rm 'module.<имя>.vault_jwt_auth_backend.this[0]' # для manage_jwt_backend
terraform state rm 'module.<имя>.vault_auth_backend.approle[0]' # для manage_approle_backendОпустошение approle_roles — тоже destroy бэкенда. При manage_approle_backend = true (дефолт) бэкенд существует, только пока есть роли, поэтому approle_roles = {} планирует его уничтожение и упирается в ту же ошибку. Полный демонтаж AppRole:
terraform state rm 'module.<имя>.vault_auth_backend.approle[0]'
# в конфиге: approle_roles = {}
terraform apply
vault auth disable approle # бэкенд остался в Vault — снести руками, когда точно пораТо же ограничение у полного удаления вызова модуля: prevent_destroy на KV-маунте и бэкендах остановит destroy — сначала state rm этих ресурсов.
Kubernetes-бэкендов это не касается: удаление кластера из clusters — штатный teardown, он и должен удалять маунт (вместе с ролями этого кластера — убирайте их из k8s_roles тем же коммитом). Помните, что смена ключа кластера или auth_path — это тоже destroy+create.
Единственное изменение — удалён вход token_reviewer_jwts: модуль больше не настраивает reviewer JWT у kubernetes auth. Причины: это долгоживущий токен с system:auth-delegator, он попадал в Terraform state открытым текстом (sensitive прячет значение только из вывода, а write-only полей у провайдера Vault нет), и для современных кластеров он не нужен.
Как Vault валидирует логин без него:
- Vault внутри кластера (
disable_local_ca_jwt = false) — своим локальным SA-токеном, как и раньше; - Vault снаружи — самим клиентским JWT: под проекцирует TokenRequest-токен с аудиторией, роль задаёт её в
audience.
Порядок перехода:
- Убедитесь, что поды логинятся проекционными токенами с аудиторией и в ролях задан
audience(для in-cluster Vault ничего не нужно). - Уберите
token_reviewer_jwtsиз вызова модуля, обновите?ref=. terraform apply— конфиг auth-метода перезаписывается без reviewer'а, значение исчезает и из Vault, и из state.- Отзовите сам токен в кластере (удалите Secret сервисаккаунта
vault-tokenreviewerили весь SA): он всё ещё валиден и остаётся в старых копиях/бэкапах state.
Если reviewer вам всё же необходим (легаси-клиенты с долгоживущими токенами без аудиторий) — оставайтесь на v2.1.1: там вход есть и задокументирован.
Адреса ресурсов не менялись. Два изменения затрагивают выданные права и поведение plan:
- Писатель теряет запись в
metadata/(KV v2,allow_destroy = false):planпокажет in-place правку политик —metadata/…сужается доread, list. Это закрытие обхода:create/updateнаmetadata/позволяли безвозвратно стереть историю секрета черезmax-versions=1. Если чей-то workflow писал custom-metadata — верните право точечно черезraw_path_capabilities(пример в разделе про KV). - Toggle
manage_*вfalseтеперь блокируетсяprevent_destroyвместо тихого уничтожения бэкенда со всеми ролями. Порядок отключения — раздел «Снять объект с управления».
Остальное строже только там, где раньше был молчаливый отказ или падение на apply: deny-конфликт, пустые списки прав, кривые элементы путей, пустые ключи карт, отрицательные TTL валят plan. Единственное послабление: token_period = 0 больше не считается периодическим токеном — в 2.0.0 такой конфиг ложно валил plan.
jwt_validation_pubkeys теперь обрезаются trimspace: у кого ключ шёл из file(), исчезнет вечный diff на vault_jwt_auth_backend.
Поднялась нижняя граница Terraform: с 1.5 до 1.12. Причина — в разделе «Требования» выше. Обновлять Terraform придётся до перехода на 2.0.
Адреса ресурсов не изменились — moved-блоки и импорт не нужны. Но plan теперь падает там, где 1.x молча продолжал, поэтому обновляться стоит не в тот же заход, что и правку прав.
Что чинить перед обновлением:
| Что | Было в 1.x | Стало в 2.0 |
|---|---|---|
AppRole с непустым approle_roles |
plan падал с Unsupported attribute на token_period — метод был нерабочим целиком |
работает; появились token_period и token_no_default_policy |
| Опечатка в capability | правило молча превращалось в capabilities = []: политика применялась, прав не давала |
валит plan |
mounts[*].kv_version кроме 1/2 |
молча трактовалось как v1 | валит plan |
| Одно имя политики или роли из двух источников | Warning в plan, apply шёл, побеждал последний источник |
валит plan |
token_ttl > token_explicit_max_ttl, token_period с потолком |
проверялось только у JWT-ролей | проверяется у kubernetes и AppRole тоже |
Прогоните terraform plan до мержа: если модуль ругается на коллизию имён или на capability, значит в 1.x эта часть конфига уже не работала так, как выглядела.
manage_approle_backend появился со значением true, а не false как у manage_kv_mount и manage_jwt_backend, — именно чтобы обновление ничего не сломало: в 1.x бэкенд создавался безусловно, и дефолт false снёс бы его вместе со всеми ролями. Если AppRole-бэкенд у вас заведён вне Terraform, поставьте false явно.
Релизы помечаются тегами vX.Y.Z; в source всегда указывается ?ref= на тег, а не на ветку — иначе очередной коммит в main уедет в прод при ближайшем terraform init -upgrade.
- major — несовместимая правка входов/выходов или изменение адресов ресурсов (потребителю нужны
moved-блоки или импорт); - minor — новые входы с дефолтами, новые проверки;
- patch — правки, не меняющие поведение.
- examples/service — одна запись
services: политика + роль; - examples/jwt — то же раздельными
policiesиjwt_roles; - examples/minimal — политика, роль kubernetes и AppRole.
Все три проверяются через terraform init && terraform validate; применять не обязательно.
Чтобы не тегировать каждый шаг, у потребителя временно подменяют источник на путь:
module "access" {
source = "../../terraform-module-vault" # временно, вернуть на git:: перед мержем
}После правок:
terraform fmt -recursive
terraform test # в корне модуляterraform test живого Vault не требует: все прогоны — command = plan, тело политики известно до apply. В tests/ проверяются раскладка путей v1/v2, слияние пересекающихся путей, микс маунтов и все precondition/validation.
CI гоняет тесты на двух версиях — 1.12.0 (объявленная нижняя граница) и latest. Прогон на границе здесь не формальность: расхождение в вычислении || между 1.11 и 1.12 ловится только так.
terraform validate одного его не заменяет. Terraform не типизирует each.value статически, поэтому обращение к несуществующему полю объекта validate проходит и падает только на plan — ровно так в 1.7.0 уехал сломанный AppRole. Тесты этот класс ошибок ловят, validate — нет.
MIT, см. LICENSE.