Vault JWT auth из GitLab CI: без статических токенов

Published: 2026-05-21

Статические Vault-токены в CI-переменных — это риск: они не истекают, лишены контекста, и один утёкший .gitlab-ci.yml открывает доступ ко всем секретам, которые может прочитать токен. Метод JWT auth решает эту проблему: GitLab выдаёт подписанный OIDC-токен на каждый job, Vault проверяет его по JWKS-эндпоинту GitLab и возвращает краткосрочный Vault-токен ровно с той политикой, которая задана для роли.


Как работает GitLab JWT

В каждом GitLab CI job доступна переменная CI_JOB_JWT — подписанный JWT с клеймами project_path, ref, environment и user_login. Метод JWT auth в Vault настраивается на JWKS URL GitLab и проверяет подпись токена — никаких заранее разделяемых секретов не требуется.

Последовательность:

  1. GitLab выпускает JWT, подписанный своим приватным ключом
  2. CI job вызывает vault write auth/jwt/login role=... jwt=$CI_JOB_JWT
  3. Vault скачивает JWKS GitLab и проверяет подпись JWT
  4. Vault сверяет bound_claims с клеймами в payload JWT
  5. Если все проверки пройдены — Vault возвращает краткосрочный токен

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

bash# Включить JWT auth
vault auth enable jwt

# Указать на JWKS GitLab
vault write auth/jwt/config \
  jwks_url="https://gitlab.example.com/-/jwks" \
  bound_issuer="https://gitlab.example.com"

Роли для каждой среды

bashvault write auth/jwt/role/access-Development \
  role_type="jwt" \
  bound_audiences="https://vault.test.antonnovikov.com" \
  user_claim="user_login" \
  bound_claims='{"project_path": ["company/backend/*"], "ref": ["dev", "feat/*"]}' \
  policies="app-development" \
  ttl="1h"

vault write auth/jwt/role/access-Production \
  role_type="jwt" \
  bound_audiences="https://vault.test.antonnovikov.com" \
  user_claim="user_login" \
  bound_claims='{"project_path": ["company/backend/*"], "ref": ["rel/*"]}' \
  policies="app-production" \
  ttl="30m"

Ключевые моменты:

  • bound_claims.ref ограничивает, с каких git-веток можно деплоить в prod — только rel/*.
  • ttl=30m для production. Job должен завершиться в этом окне.
  • Каждая среда получает собственную политику с отдельными KV-путями.
  • Glob-паттерны в project_path позволяют нескольким сервисам использовать одну роль.

Политика Vault

hcl# Политика app-development
path "Microservices/+/Development/*" {
  capabilities = ["read"]
}
path "common-secret/Development/" {
  capabilities = ["read"]
}
hcl# Политика app-production
path "Microservices/+/Production/*" {
  capabilities = ["read"]
}
path "common-secret/Production/" {
  capabilities = ["read"]
}

Политики дают только read. Никакого create, update, delete — CI никогда не должен изменять секреты.


Сторона CI

yamlvariables:
  VAULT_ADDR: https://vault.test.antonnovikov.com
  DOCKER_IMAGE: registry.example.com/ci-images/vault-cli:1.0.1

.auth:jwt: &auth_jwt |
  export VAULT_TOKEN="$(vault write -field=token auth/jwt/login \
    role=access-${APP_ENV} \
    jwt=$CI_JOB_JWT)"
  vault kv get -field=kubeconfig_${NAMESPACE} common-secret/${APP_ENV} > kube_config
  chmod 600 kube_config
  export KUBECONFIG=kube_config

deploy_dev:
  image: ${DOCKER_IMAGE}
  stage: deploy
  before_script:
    - *auth_jwt
  script:
    - helm upgrade --install ...
  variables:
    APP_ENV: Development
    NAMESPACE: app

deploy_prod:
  image: ${DOCKER_IMAGE}
  stage: deploy
  before_script:
    - *auth_jwt
  script:
    - helm upgrade --install ...
  variables:
    APP_ENV: Production
    NAMESPACE: app
  rules:
    - if: $CI_COMMIT_REF_NAME =~ /^rel\//

YAML-якорь *auth_jwt объявляется один раз и переиспользуется в каждом deploy job. Меняется только APP_ENV.


Кастомный CI-образ

Образ vault-cli содержит vault, consul-template, helm, kubectl и curl. Тег в DOCKER_IMAGE зафиксирован — каждый deploy job получает одно и то же окружение независимо от изменений в upstream:

dockerfileFROM hashicorp/vault:1.15
RUN apk add --no-cache curl helm kubectl
COPY --from=hashicorp/consul-template:0.33 /bin/consul-template /bin/consul-template

Kubeconfig из Vault

Вместо хранения kubeconfig в CI-переменной GitLab (там он виден открытым текстом в интерфейсе) — хранить его в Vault под common-secret/<env>/kubeconfig_<cluster> и забирать при деплое:

bashvault kv get -field=kubeconfig_app common-secret/Production > kube_config
chmod 600 kube_config
export KUBECONFIG=kube_config

Kubeconfig содержит service account token, ограниченный правом helm upgrade в конкретном namespace. Никакого cluster-admin. Никакого постоянного доступа к shell.


Что нельзя сделать с токеном

Возможность Статус
Доступ к секретам другой среды Невозможно — bound_claims не пропустит
Использовать токен после завершения job Нет — ttl истёк
Деплоить prod с feature-ветки Нет — bound_claims.ref допускает только rel/*
Изменять секреты Vault Нет — политика даёт только read
Доступ к секретам других проектов Нет — клейм project_path ограничен

Отладка ошибок аутентификации

bash# Ручная проверка JWT auth
vault write auth/jwt/login \
  role=access-Development \
  jwt=$CI_JOB_JWT

# Декодировать клеймы JWT (без проверки подписи)
echo $CI_JOB_JWT | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Список настроенных ролей
vault list auth/jwt/role

# Детали роли
vault read auth/jwt/role/access-Development

Типичные ошибки:

  • bound claim mismatchref или project_path в JWT не соответствует bound_claims роли
  • role not found — опечатка в APP_ENV
  • invalid audiencebound_audiences не совпадает с URL вашего Vault