Принятие роли и федерация
Долгоживущие ключи доступа — это то, что утекает. Assume-role выдаёт короткоживущие STS-учётки для конкретной задачи; межаккаунтный доступ использует trust-политику с external id; CI и поды федерируются через OIDC, так что красть просто нечего.
GitHub Action запускает terraform apply против продакшена. Чтобы это сработало, кто-то два года назад вставил AWS_SECRET_ACCESS_KEY в CI-секреты репозитория — долгоживущий ключ, привязанный к роли с широкими правами. Ключ никогда не истекает, про его существование уже никто не помнит, и он лежит открытым текстом в окружении раннера на каждой сборке. Один утёкший лог воркфлоу, одна вредоносная зависимость в сборке, один форк с pull_request_target — и этот ключ уходит за дверь, всё ещё валидный, всё ещё привилегированный, пригодный из любой точки планеты, пока человек не заметит и не проротирует его. Решение — не лучшее хранилище секретов. Решение — вообще перестать иметь постоянный ключ: пусть раннер докажет, кто он, и получит учётки, которые мертвы уже через пятнадцать минут.
К концу урока ты будешь понимать, как AssumeRole выдаёт короткоживущие учётки, как межаккаунтная trust-политика и external id позволяют одному аккаунту безопасно действовать в другом, и как OIDC-федерация даёт CI и Kubernetes-нагрузкам доступ в облако вообще без хранимого ключа.
Проблема: постоянные ключи — это то, что утекает
У долгоживущего ключа доступа (пара AKIA..., JSON-файл сервис-аккаунта) есть три свойства, которые делают его самым массово эксфильтруемым облачным credential: он не истекает, он bearer-only (кто держит байты — тот и есть личность, никакого доказательства владения), и он пригоден из любой сети на земле. Поэтому ключ, утёкший в строке лога в 3 часа ночи, остаётся полностью валидной личностью к полудню следующего дня. Вся цель принятия роли — заменить этот постоянный ключ на credential, который ограничен задачей, по умолчанию короток и привязан к личности, которую надо доказать заново, чтобы получить ещё.
Механизм — это временные учётки безопасности. Вместо того чтобы держать ключ под нужные тебе права, ты держишь право попросить их. Ты вызываешь Security Token Service (sts:AssumeRole), называешь роль, которую тебе разрешено принять, и STS возвращает тройку — идентификатор ключа доступа, секрет и session token, — которая истекает по таймеру, заданному тобой (от 15 минут до 12 часов; час — частый дефолт). Когда она истекает, она инертна: утёкший session token, поднятый после своего TTL, ничего не стоит. Ты превратил постоянную уязвимость в скоропортящуюся.
Как на самом деле работает assume-role
Прежде чем STS что-либо выдаст, должны сойтись две политики, и их смешение — самая частая ошибка IAM по этой теме:
- Trust-политика (
AssumeRolePolicyDocument, привязанная к роли) отвечает на вопрос «кому разрешено стать этой ролью?» ЕёPrincipalназывает личности — аккаунт, пользователя, другую роль или федеративного провайдера, — которым разрешено вызыватьAssumeRoleдля неё. - Политика прав (привязанная к роли) отвечает на вопрос «раз ты стал этой ролью, что ты можешь делать?» — собственно
s3:GetObject,dynamodb:Queryи т. д.
Запрос на принятие роли отклоняется, если вызывающий одновременно не назван в trust-политике роли и не имеет права sts:AssumeRole на своей стороне. Это двустороннее рукопожатие и есть вся модель безопасности: владелец роли решает, кто может войти, а аккаунт вызывающего решает, может ли он пытаться. Когда STS одобряет, он возвращает короткоживущие учётки с правами роли — не вызывающего. Их можно сузить ещё на этапе принятия с помощью session-политики (inline-политики, переданной в вызове AssumeRole, которая может только вычитать права) — так ты выдаёшь под-задаче даже меньше, чем даёт роль.
Межаккаунтный доступ: trust-политика и фикс «запутанного помощника»
Межаккаунтный assume-role — это как CI-аккаунт, аккаунт аудиторской тулзы или сторонний SaaS получает ограниченный доступ в твой аккаунт, при этом ты ни разу не создаёшь для них пользователя. Ты создаёшь роль в своём аккаунте, чья trust-политика называет их аккаунт принципалом, привязываешь плотную политику прав и отдаёшь им ARN роли. Они принимают её со своей стороны и работают внутри твоего аккаунта как эта роль — никогда как долгоживущий ключ, который ты сгенерировал и отправил им.
Но у межаккаунтного доверия есть классическая ловушка: запутанный помощник (confused deputy). Допустим, SaaS-вендор принимает роль в аккаунте каждого клиента, используя один и тот же вендорский аккаунт как принципал. Если вендора обманом заставят использовать ARN твоей роли, действуя в интересах другого клиента, — или атакующий, знающий ARN твоей роли, сможет дотянуться до вендора, — к твоему аккаунту обращаются под легитимно доверенной личностью вендора. Фикс — это external id: выбранная тобой секретная строка, записанная в условие твоей trust-политики (sts:ExternalId), которую вендор обязан передавать на каждом AssumeRole. Поскольку она своя для каждого клиента и неугадываема, атакующий, знающий только ARN роли, не сможет заставить помощника применить её против тебя. Для своих собственных нагрузок современный эквивалент — условия OIDC по claim-ам субъекта (ниже); паттерн с external id — именно для третьих сторон, которых нельзя федерировать.
▸Почему это работает
Почему час, а не минута? Короче — безопаснее на один credential, но каждое истечение вынуждает round-trip повторного принятия, а у STS есть лимиты частоты запросов на уровне аккаунта. TTL в 15 минут на болтливом сервисе означает постоянную переаутентификацию и реальный шанс на throttling под нагрузкой; TTL в 12 часов на интерактивной админ-сессии — длинное окно для украденного токена. Зрелый рефлекс — сопоставлять TTL с радиусом поражения: минуты для высокопривилегированных или межаккаунтных ролей, час для обычной сервисной работы, и пусть credential-провайдер SDK авто-обновляет учётки, чтобы ротация была невидима коду. Смысл короткоживущих учёток не в максимуме трения — а в том, чтобы утёкший токен истёк раньше, чем станет полезен.
Федерация: CI и нагрузки без единого хранимого ключа
Финал убирает bootstrap-ключ целиком. Федерация позволяет личности извне собственного IAM облака — запуску GitHub Actions, поду Kubernetes, входу через Google или Okta — обменять уже имеющийся у неё токен на короткоживущие облачные учётки, при этом ни один ключ нигде не хранится.
Доминирующий паттерн для машин — OIDC. GitHub Actions, GitLab CI и Kubernetes все действуют как OIDC-провайдеры личности: во время выполнения они выпускают подписанный короткоживущий JWT, утверждающий, кто запущен, — репозиторий, ветку, воркфлоу, сервис-аккаунт. Ты регистрируешь этого провайдера в облачном аккаунте один раз и создаёшь роль, чья trust-политика говорит: «доверять токенам от token.actions.githubusercontent.com, но только когда claim sub равен repo:acme/api:ref:refs/heads/main». CI-задача вызывает AssumeRoleWithWebIdentity, предъявляет свой JWT, STS проверяет подпись по опубликованным ключам провайдера и проверяет условия по claim-ам, и возвращает короткоживущие учётки. Никакой секрет в CI никогда не хранился. Утёкший лог воркфлоу содержит, самое большее, уже истекающий токен, привязанный к одному репозиторию и одной ветке.
Та же идея работает внутри Kubernetes как workload identity (EKS IRSA, GKE Workload Identity): сервис-аккаунт пода привязан к облачной роли, kubelet проецирует в под подписанный JWT сервис-аккаунта, и SDK обменивает его на учётки, ограниченные этой нагрузкой, — так что два пода на одном узле получают разные минимальные права вместо общей роли узла. Условие trust-политики в каждом случае делает основную работу: федерация безопасна ровно настолько, насколько плотно ты закрепил claim-ы sub/aud. Trust-политика, доверяющая всей GitHub-организации вместо одного репозитория, или опускающая aud, — это федеративный wildcard — та же ошибка радиуса поражения, что и *:*, просто в OIDC-костюме.
| Паттерн доступа | Хранимый credential | Время жизни | Ворота доверия | Радиус поражения при утечке |
|---|---|---|---|---|
| Долгоживущий ключ доступа | Хранимый секрет (AKIA…) | До ротации человеком | Bearer — нет | Полный, бессрочный, откуда угодно |
| Assume-role (тот же аккаунт) | STS session-учётки | TTL 15 мин – 12 ч | Право вызывающего + trust-политика | Рамки роли, только до TTL |
| Межаккаунтный assume-role | STS session-учётки | TTL 15 мин – 12 ч | Trust-политика + external id | Рамки роли в твоём аккаунте, до TTL |
| OIDC-федерация (CI / под) | Не хранится — JWT выпускается в рантайме | Минуты (токен + TTL STS) | Подпись провайдера + закреп claim sub/aud | Один репо/ветка/под, уже истекает |
Как рассуждает зрелый инженер
Порядок предпочтений — это лестница, и ты поднимаешься настолько высоко, насколько позволяет платформа. Для своих нагрузок внутри облака привяжи роль к вычислителю (instance profile, IRSA, workload identity), чтобы учётки доставлялись и ротировались автоматически и ключ не существовал. Для CI и внешних-но-федерируемых систем используй OIDC с плотно закреплёнными условиями по claim-ам. Для третьих сторон, которых нельзя федерировать, используй межаккаунтный assume-role с external id. Статический ключ доступа — нижняя ступень, оправданная, только когда ничего выше недоступно, и тогда он обязан быть короткого TTL, узко ограниченным, с алертом на использование и ротируемым по расписанию. Каждый шаг вверх по лестнице убирает хранимый секрет, сокращает время жизни или ужесточает условие доверия — и в этом вся игра: уменьшить, что даёт утечка и сколько она длится.
Твоему GitHub Actions пайплайну нужно деплоить в продакшен-аккаунт облака. Сегодня он использует долгоживущий ключ доступа, хранимый в CI-секретах. Выбери лучшую замену.
У роли в аккаунте B есть политика прав, дающая s3:*, но её trust-политика не перечисляет ни одного принципала. Пользователь в аккаунте A с правом sts:AssumeRole пытается её принять. Что произойдёт?
Почему OIDC-федерация для CI устраняет риск утечки ключа, который есть у хранимого долгоживущего ключа?
Упорядочи шаги OIDC-федеративного CI-деплоя, от старта до выданных учёток:
- 1 Один раз зарегистрировать CI-провайдера и создать роль, чья trust-политика закрепляет claim-ы sub/aud
- 2 В рантайме CI-задача выпускает подписанный короткоживущий OIDC JWT, утверждающий репо + ветку
- 3 Задача вызывает AssumeRoleWithWebIdentity, предъявляя JWT
- 4 STS проверяет подпись по ключам провайдера и проверяет условия по claim-ам
- 5 STS возвращает короткоживущие учётки в рамках роли; сборка деплоит
- 01Объясни, как работает AssumeRole, какие две политики участвуют и почему возвращаемые им учётки безопаснее долгоживущего ключа доступа.
- 02Что такое проблема запутанного помощника в межаккаунтном assume-role и как external id и условия по OIDC-claim-ам от неё защищают?
Долгоживущие ключи доступа — самый эксфильтруемый облачный credential, потому что они никогда не истекают, они bearer-only и работают откуда угодно: утечка в 3 часа ночи всё ещё валидна к полудню. Принятие роли заменяет их временными STS-учётками, ограниченными задачей и истекающими по заданному тобой TTL (от 15 минут до 12 часов). AssumeRole — двустороннее рукопожатие: вызывающему нужно sts:AssumeRole на своей стороне, и он должен быть назван в trust-политике роли, которая отдельна от политики прав, решающей, что роль может делать после принятия. Межаккаунтный доступ работает через доверие другому аккаунту в trust-политике роли, а условие external id закрывает дыру запутанного помощника для третьих сторон, которых нельзя федерировать. Финал — федерация: CI-системы и Kubernetes-нагрузки действуют как OIDC-провайдеры, выпуская короткоживущие подписанные JWT в рантайме, которые STS обменивает на учётки через AssumeRoleWithWebIdentity, — так что никакой секрет никогда не хранится, и утёкший лог держит самое большее уже истекающий токен, закреплённый за одним репо, веткой или подом. Безопасность любой федеративной настройки живёт в том, насколько плотно trust-политика закрепляет claim-ы sub и aud: доверие всей организации вместо одного репо — это федеративный wildcard, та же ошибка радиуса поражения, что и политика :. Рефлекс — лестница: сначала привязанная роль, затем OIDC, затем межаккаунтный доступ с external id, и статический ключ только как оправданный последний вариант с коротким TTL — каждая ступень убирает хранимый секрет, сокращает время жизни или ужесточает условие доверия.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.