Зачем
До OIDC у каждой команды в CI был долгоживущий AWS IAM User с двумя
секретами в GitHub: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY. Все
knows:
- Утечка через лог = катастрофа.
- Ротация ручная, делается раз в год вместо раз в 90 дней.
- Один ключ, один CI; невозможно ограничить «можно только из main».
- Forensics: «кто это сделал?», невозможно, потому что любой CI-action шёл от одного и того же IAM user.
OIDC: AWS доверяет GitHub как identity provider. GitHub при запуске workflow выдаёт runner'у короткий JWT (5 минут). Runner идёт в STS AssumeRoleWithWebIdentity → получает временные credentials (1 час). Никаких долгих секретов; trust policy ограничивает trust до конкретного repo/branch/environment.
Шаг 1: IAM OIDC Provider
Создаётся один раз per AWS-account.
Через CLI:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Через Terraform:
resource "aws_iam_openid_connect_provider" "github" {url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}
Thumbprint, отпечаток SSL-сертификата GitHub. AWS его проверяет. Иногда GitHub меняет cert и thumbprint становится stale (старая проблема). Сейчас рекомендуется опускать thumbprint, AWS делает верификацию через PKI бренд (с лета 2023).
Шаг 2: IAM Role с trust policy
resource "aws_iam_role" "github_tf_runner" {name = "github-tf-runner"
assume_role_policy = jsonencode({Version = "2012-10-17"
Statement = [{Effect = "Allow"
Principal = {Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = { StringEquals = {"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {"token.actions.githubusercontent.com:sub" = "repo:linuxlab/terraform-infra:ref:refs/heads/main"
}
}
}]
})
}
resource "aws_iam_role_policy_attachment" "github_tf_runner" {role = aws_iam_role.github_tf_runner.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" # или Custom
}
Sub-claim, самое важное:
Значение sub | Что разрешает |
|---|---|
repo:org/repo:ref:refs/heads/main | Только workflow на main |
repo:org/repo:ref:refs/tags/* | Любой тег |
repo:org/repo:pull_request | Только PR-context (read-only обычно) |
repo:org/repo:environment:prod | Workflow с environment: prod |
repo:org/*:* | Слишком широко, любой workflow орга |
Конкретнее = безопаснее. Для prod-роли: :environment:prod и pin'нуть
одно environment, требующее approval.
Шаг 3: Workflow
# .github/workflows/tf-apply.yml
permissions:
id-token: write # обязательно для OIDC
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: prod # привязка к prod-роли
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT:role/github-tf-runner
aws-region: us-east-1
role-session-name: gha-${{ github.run_id }}- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform apply -auto-approve plan.tfplan
permissions: id-token: write, без неё GitHub не выдаст OIDC-токен,
и action provider'а падает с «No OIDC token available».
role-session-name: gha-${{ github.run_id }}, название сессии в STS.
В CloudTrail видно, какая run-id создала ресурс. Forensics-friendly.
Несколько ролей: dev / stage / prod
Антипаттерн: одна роль для всех env. Если CI на dev-ветке взломан, он ходит и в prod.
Правильно:
| Роль | Sub-claim | Permissions |
|---|---|---|
github-tf-runner-dev | :ref:refs/heads/* | dev-account или scoped IAM |
github-tf-runner-stage | :environment:stage | stage-resources |
github-tf-runner-prod | :environment:prod | prod-resources |
В каждом workflow указываешь свою role-to-assume в зависимости от
целевой среды.
Read-only plan-роль
Plan-job нужен только AWS-read (для refresh). Apply-job, read+write. Split:
resource "aws_iam_role" "github_tf_plan" {name = "github-tf-plan"
assume_role_policy = jsonencode({ Statement = [{Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }Action = "sts:AssumeRoleWithWebIdentity"
Condition = { StringLike = {"token.actions.githubusercontent.com:sub" = "repo:linuxlab/terraform-infra:pull_request"
}
}
}]
Version = "2012-10-17"
})
}
resource "aws_iam_role_policy_attachment" "plan_readonly" {role = aws_iam_role.github_tf_plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
Плана не нужно s3:CreateBucket. ReadOnlyAccess (или собственный custom
с view-permissions для покрываемых сервисов) хватает. Compromised PR
не может ничего создать.
Подводные камни
-
Sub-claim ошибка, vulnerability.
repo:org/*:*означает «любой repo в org может assume». Доверяешь всем team-mate'ам, всем fork'ам, всем pull_request'ам. Всегда конкретный repo + конкретный ref/environment. -
permissions: id-token: writeзабывают на subworkflow. Если выполняется черезuses: ./.github/workflows/tf.yml(reusable workflow), он не наследует permissions парента. Прописывать в каждом workflow или вdefaults. -
Sessions длятся 1 час. Долгий apply (1.5h) умрёт на отказе в refresh credentials. Решение: бить apply на куски (
-targetне рекомендую, лучше split-modules), или поднятьDurationSecondsв role (max-session-durationдо 12h) + явныйrole-duration-secondsв action. -
OIDC не работает с self-hosted runners по default. Self-hosted нуждается в правильно настроенном
ACTIONS_ID_TOKEN_REQUEST_URLокружении (GitHub его выставляет на GitHub-hosted, на self-hosted нет). Для self-hosted: либо use GitHub-hosted для apply, либо отдельная конфигурация. -
Thumbprint от старой документации. Старые туторы говорят «обязателен thumbprint`. С 2023 AWS делает PKI-валидацию, thumbprint игнорируется. Прописал стейл значение, провайдер всё равно работает, но не доверяй ему как security-control.
-
OIDC ≠ ноль секретов. Сам role-arn, это «полу-секрет». Если публикуешь в public-репо, atttacker знает, какую роль assume'ить (хотя без OIDC-токена из его workflow ничего не сделает). Trust-policy всё равно держит.
-
CloudTrail для OIDC-roles тяжелее читать.
Principalв логах federated identity, ID сессии, gha-run_id. Сделай query-saver в Athena/CloudWatch чтобы быстро находить «кто apply'ил».