Заметка

OIDC → AWS STS: CI/CD без долгоживущих ключей

Федеративная идентичность как замена AWS_ACCESS_KEY_ID в CI/CD: единый паттерн для GitHub, GitLab и Atlantis, без ротации и с настоящей атрибуцией в CloudTrail.

AWS_ACCESS_KEY_ID в CI-переменных — стандарт 2018-го, anti-pattern 2026-го. Федеративная идентичность через OIDC даёт CI/CD короткоживущие STS-credentials по sub-клейму токена, а в CloudTrail оседает не безымянный machine-user, а конкретный workflow с веткой и репозиторием. Единый паттерн работает и для GitHub Actions, и для GitLab CI, и для self-hosted Atlantis.

Что не так с долгоживущим ключом

aws_iam_access_key живёт месяцами и хранится в secrets репозитория или CI-переменной. Это даёт сразу три проблемы, которые не лечатся «лучшей политикой паролей»:

  1. Ручная ротация. Перевыпустить ключ — это PR в SaaS, новое значение в каждый секрет каждого репозитория, и надежда, что никто не положил его в ~/.aws/credentials на ноутбуке.
  2. Blast radius при leak. Ключ с правом terraform apply валидно работает хоть из CI, хоть из Slack-чата атакующего. Сетевая граница — 0.0.0.0/0.
  3. CloudTrail без атрибуции. eventSource=ec2.amazonaws.com, userIdentity.userName=ci-deployer-prod, и никакой связи с тем, какой PR и чьей кнопкой это запустил.

OIDC как замена: три действующих лица

Федеративный сценарий заменяет «передать ключ» на «обменять токен на короткую сессию». В нём три действующих лица:

  • CI-провайдер — выпускает подписанный JWT (id_token) с клеймами: репозиторий, ветка, environment, project_id.
  • IAM OIDC Identity Provider на AWS — один на провайдера, ARN формы arn:aws:iam::<acc>:oidc-provider/<issuer-host>. Audience всегда sts.amazonaws.com.
  • IAM Role с trust policyFederated principal указывает на OIDC-провайдера; Condition отсеивает запросы по sub-клейму.

Когда job начинается, CI кладёт JWT во временную переменную и вызывает sts:AssumeRoleWithWebIdentity. AWS проверяет подпись токена, сверяет aud и sub с trust policy и возвращает AccessKeyId/SecretAccessKey/SessionToken, действующие час или меньше. В CloudTrail остаётся не iam-user/ci-prod, а arn:aws:sts::…:assumed-role/<role>/<run-id> плюс sub в webIdFederationData.

Минимальный setup на трёх провайдерах

GitHub Actions — issuer token.actions.githubusercontent.com, sub-формат repo:org/repo:ref:refs/heads/main (или :environment:prod, или :pull_request):

permissions:
  id-token: write     # обязательно
  contents: read
jobs:
  deploy:
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<acct>:role/gha-deploy
          aws-region: eu-central-1
      - run: terraform apply -auto-approve

GitLab CI (SaaS) — issuer https://gitlab.com, sub-формат project_path:group/project:ref_type:branch:ref:main. JWT приходит как id_tokens, обмен — aws sts assume-role-with-web-identity явным шагом.

Self-hosted Atlantis — issuer берётся либо у самого Atlantis, либо у IDP за ним; scope — per-project IAM role, не общая «atlantis-runner».

Trust policy одинакова с точностью до подстановки issuer и sub-pattern:

resource "aws_iam_role" "ci_deploy" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Action = "sts:AssumeRoleWithWebIdentity"
      Principal = { Federated = "arn:aws:iam::${var.acc}:oidc-provider/${var.issuer}" }
      Condition = {
        StringEquals = { "${var.issuer}:aud" = "sts.amazonaws.com" }
        StringLike   = { "${var.issuer}:sub" = var.sub_pattern }
      }
    }]
  })
}

Чего нельзя делать, даже когда «работает»

Три анти-паттерна, которые регулярно встречаются в публичных репозиториях и обнуляют выигрыш от OIDC:

  • AdministratorAccess на OIDC-роли «для демо». Federated-роль обязана быть least-privilege. Иначе при компрометации build-скрипта (зависимость от npm-пакета, supply-chain) злоумышленник делает то же, что давал ваш iam-user, только с лучшей атрибуцией — никакого выигрыша.
  • Один job делает npm build и terraform apply. Build исполняется на default-runner'е, может тянуть произвольные зависимости, а потом этот же процесс получает временный токен с правами в проде. Правильно — build ассумит ноль ролей, артефакт уезжает в registry, отдельный apply job вызывает configure-aws-credentials. Радиус взрыва компрометации зависимости сжимается до прав на pull.
  • AWS_ACCESS_KEY_ID в env CI-runner'а живёт «для совместимости». Переменные окружения перебивают AWS_PROFILE в aws-CLI; одна забытая AWS_ACCESS_KEY_ID=… в groups-level variables — и OIDC-сессия игнорируется. В CI-shell перед terraform всегда unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN.

Отдельная ловушка для Terraform: module { source = "s3::…" } не уважает AWS_PROFILE — модули скачаются под дефолтными credentials. Лечится двумя способами: aws configure export-credentials --format env перед terraform init, или хранить модули в Git/registry, а не в S3.

Что вы перестанете делать

После миграции на OIDC исчезает рутинный поток задач: ротация ключей, ревью, кто и когда добавил AWS_* в secrets, аудит покинувших команду людей с ключами на ноутбуках. Постоянных секретов в CI остаётся ноль; периметр атаки CI/CD сжимается до возможности подделать JWT-подпись GitHub или GitLab — что в текущей модели угроз эквивалентно компрометации самого провайдера.

Это та редкая security-миграция, которая не добавляет процедур, а удаляет их.