Kubernetes для самых маленьких. Часть 29: Как устроены аутентификация и RBAC?

Все части серии «Kubernetes для самых маленьких»

До сих пор мы работали с кластером от имени администратора из файла admin.conf, который kubeadm создал в части 4, и ни разу не получили отказа. А в прошлой части контроллеру понадобились права на greetings и greetings/status, а оператору PostgreSQL — на Secret во всех неймспейсах, и мы отложили вопрос, откуда они берутся и чем опасны. Сегодня о том, как kube-apiserver узнаёт, кто к нему пришёл, и решает, что этому субъекту можно. Протокол TLS, сертификаты и цепочки доверия разобраны в части 23 сетевой серии; здесь важно одно: всё общение с apiserver идёт по TLS, и сертификат клиента может быть его удостоверением.

Всё в этой части проверено на учебном кластере: сертификаты и CSR, RBAC, токены ServiceAccount, ограниченная имперсонация и AuthenticationConfiguration — с тестовым провайдером OIDC, выдававшим подписанные токены. Без проверки остались kubelogin и вход через настоящий провайдер (Keycloak, Dex), webhook-аутентификация, статический файл токенов и удаление старых Secret с токенами — о них по документации.


Путь запроса

Каждый запрос к API проходит одну и ту же цепочку:

 клиент (kubectl, kubelet, контроллер, под)
        │  HTTPS :6443
        ▼
 ┌─────────────────────┐
 │  аутентификация     │  кто это? → имя, UID, группы, доп. атрибуты
 │  (authenticators)   │  не узнали → system:anonymous или 401
 └──────────┬──────────┘
            ▼
 ┌─────────────────────┐
 │  авторизация        │  можно ли ему этот глагол на этот ресурс?
 │  (Node, RBAC, ...)  │  нет → 403 Forbidden
 └──────────┬──────────┘
            ▼
 ┌─────────────────────┐
 │  admission          │  изменить или отклонить объект по правилам
 │  (mutating,         │  (квоты, Pod Security, вебхуки — часть 30)
 │   validating)       │
 └──────────┬──────────┘
            ▼
         etcd

Аутентификация отвечает на вопрос «кто», авторизация — «можно ли», admission — «можно ли такой объект». Сегодня — первые два звена.


Аутентификация: пользователей нет

В Kubernetes нет объекта «пользователь». Нельзя сделать kubectl create user, нет списка пользователей, нет паролей в etcd. Пользователь — это строка, которую apiserver извлёк из удостоверения, предъявленного в запросе, и которой он доверяет, потому что удостоверение подписал тот, кому доверяет он сам: свой центр сертификации, свой ключ подписи токенов, внешний провайдер.

Результат аутентификации — набор атрибутов:

Атрибут Пример
имя (username) alice, system:serviceaccount:demo:hello, system:node:k8s-node-1
UID уникальный идентификатор, если источник его даёт
группы platform-admins, system:authenticated
extra дополнительные пары ключ-значение (например, имя пода у токена ServiceAccount или отпечаток сертификата)

Все прошедшие аутентификацию автоматически получают группу system:authenticated. Запрос без удостоверения становится запросом пользователя system:anonymous в группе system:unauthenticated — анонимный доступ включён по умолчанию, но RBAC по умолчанию не даёт ему почти ничего, кроме проверок здоровья (/healthz, /livez, /readyz) и /version. Префикс system: зарезервирован за самим Kubernetes, и выдавать такие имена людям нельзя.

Аутентификаторов несколько, apiserver пробует их по очереди, и первый узнавший запрос побеждает. Разберём каждый.


Клиентские сертификаты X.509

Если клиент при установке TLS предъявил сертификат, подписанный CA из --client-ca-file (в kubeadm — /etc/kubernetes/pki/ca.crt), apiserver берёт:

  • CN (Common Name) субъекта — как имя пользователя;
  • каждое O (Organization) — как группу.

Сертификат CN=alice, O=platform-admins, O=developers — это пользователь alice в группах platform-admins, developers и system:authenticated. Всё. apiserver не ходит ни в какую базу: подпись CA верна, срок не истёк — значит, alice.

Так аутентифицируются компоненты самого кластера: kubelet каждого узла — сертификатом CN=system:node:k8s-node-1, O=system:nodes, kube-scheduler и kube-controller-manager — своими. И администратор из admin.conf:

user@k8s-master-1:~$ sudo grep client-certificate-data /etc/kubernetes/admin.conf | awk '{print $2}' \
    | base64 -d | openssl x509 -noout -subject -enddate
subject=O=kubeadm:cluster-admins, CN=kubernetes-admin
notAfter=Oct 24 10:31:34 2027 GMT

Группа kubeadm:cluster-admins получает роль cluster-admin через ClusterRoleBinding, который kubeadm создал при установке. Рядом лежит ещё super-admin.conf:

user@k8s-master-1:~$ sudo grep client-certificate-data /etc/kubernetes/super-admin.conf | awk '{print $2}' \
    | base64 -d | openssl x509 -noout -subject
subject=O=system:masters, CN=kubernetes-super-admin

Группа system:masters обходит авторизацию целиком: запрос от её члена разрешается до проверки RBAC, и никакие изменения ролей её не ограничат. Поэтому kubeadm с версии 1.29 и разделил два файла: admin.conf — для работы, super-admin.conf — для аварии (например, если сломали RBAC), и его стоит держать на мастере, не копируя на ноутбуки. Выдавать кому-либо сертификат с O=system:masters нельзя никогда.


Токены

Второй способ — заголовок Authorization: Bearer <токен>. Токен бывает нескольких видов.

Токены ServiceAccount. ServiceAccount — единственный объект «учётной записи», который в Kubernetes есть, и он для программ, а не для людей. Его полное имя — system:serviceaccount:<неймспейс>:<имя>, группы — system:serviceaccounts и system:serviceaccounts:<неймспейс>. Токен — это JWT (JSON Web Token: три части в base64 через точку — заголовок, набор утверждений и подпись), подписанный закрытым ключом /etc/kubernetes/pki/sa.key. apiserver проверяет подпись открытым ключом sa.pub, срок, аудиторию и то, что связанные с токеном объекты ещё существуют.

Токены выдаёт API TokenRequest — подресурс serviceaccounts/token. Каждый токен:

  • ограничен по сроку (exp): kubectl create token по умолчанию даёт час, меньше 10 минут не выдаёт;
  • ограничен по аудитории (aud): для кого выпущен. apiserver принимает только токены со своей аудиторией (по умолчанию — адрес https://kubernetes.default.svc.cluster.local), а токен, выпущенный для внешней системы (kubectl create token --audience=vault.example.com), у apiserver не пройдёт, и наоборот;
  • привязан к объекту: токен, выданный поду, содержит его имя и UID и перестаёт приниматься, как только под удалён.

В под токен попадает проецируемым томом (projected volume) kube-api-access-…: kubelet сам запрашивает токен через TokenRequest, кладёт его в /var/run/secrets/kubernetes.io/serviceaccount/token вместе с ca.crt (из ConfigMap kube-root-ca.crt, который есть в каждом неймспейсе) и файлом namespace, и обновляет до истечения — после 80% срока. Срок такого токена — около часа, но для совместимости со старыми клиентскими библиотеками, не умеющими перечитывать файл, apiserver по умолчанию продлевает его ровно до года и ведёт учёт таких «устаревших» использований в метриках и в журнале аудита (записи apiserver о том, кто какой запрос сделал).

Раньше, до 1.24, для каждого ServiceAccount автоматически создавался Secret с бессрочным токеном, не привязанным ни к чему. Сейчас такие Secret не создаются, а неиспользуемые старые сначала помечаются недействительными, а затем удаляются. Создать бессрочный токен руками (Secret типа kubernetes.io/service-account-token) по-прежнему можно, но это стоит делать только для внешней системы, которая иначе не умеет, и с отдельным ServiceAccount минимальных прав.

Bootstrap-токены — короткоживущие токены вида abcdef.0123456789abcdef в Secret в kube-system, которыми новые узлы аутентифицируются при kubeadm join (часть 4), чтобы запросить себе сертификат.

Статический файл токенов (--token-auth-file) — CSV с токенами и именами, читаемый при старте apiserver. Существует, но для людей не годится: токены бессрочные, менять — перезапуском apiserver.

Webhook. apiserver отправляет токен на внешний HTTPS-сервис объектом TokenReview и получает в ответ имя и группы. Так интегрируются облачные провайдеры и самописные системы.


OIDC: вход людей через провайдера

Для людей правильный механизм — OpenID Connect (OIDC): слой поверх OAuth 2.0 (протокола, которым приложение получает от провайдера доступ от имени пользователя, не видя его пароля), при котором пользователь входит в стороннем провайдере удостоверений и получает ID-токен: JWT, подписанный провайдером, с утверждениями о том, кто он (sub, email) и в каких группах (groups). Провайдером может быть Keycloak (открытый сервер единого входа) или Dex — лёгкий посредник, который сам проверяет пользователя в GitHub, в Active Directory или в любом каталоге LDAP (протокол чтения каталога пользователей и групп по сети: клиент подключается к серверу каталога, выполняет вход и ищет записи по фильтру). apiserver проверяет подпись ID-токена по открытым ключам провайдера, которые сам скачивает по адресу из документа /.well-known/openid-configuration, и берёт имя и группы из утверждений.

Плюсы перед сертификатами: пароли и второй фактор живут у провайдера, токен живёт минуты, уволенный сотрудник отключается в одном месте, группы приходят из каталога.

Настраивается двумя способами. Старый — флаги --oidc-issuer-url, --oidc-client-id, --oidc-username-claim, --oidc-groups-claim и т. д.: один провайдер, менять — перезапуском. Новый — структурированная конфигурация аутентификации: файл AuthenticationConfiguration, передаваемый флагом --authentication-config. Она стабильна с 1.34, позволяет несколько провайдеров, проверки утверждений на CEL (Common Expression Language — языке выражений без циклов и побочных эффектов, который apiserver вычисляет сам) и перечитывается на лету при изменении файла. Совмещать с флагами --oidc-* нельзя: apiserver с обоими не запустится.

# /etc/kubernetes/auth/authentication-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
jwt:
  - issuer:
      url: https://sso.example.com/realms/infra
      audiences:
        - kubernetes
    claimValidationRules:
      - expression: "claims.email.endsWith('@example.com')"
        message: разрешены только адреса @example.com
    claimMappings:
      username:
        claim: email
        prefix: "oidc:"
      groups:
        claim: groups
        prefix: "oidc:"
anonymous:
  enabled: true
  conditions:
    - path: /livez
    - path: /readyz
    - path: /healthz

Что здесь происходит:

  • url должен совпадать с утверждением iss в токене, а audiences — с aud: токен, выпущенный провайдером для Grafana, к apiserver не подойдёт. Если сертификат провайдера выдан внутренним CA, его PEM кладут в issuer.certificateAuthority.
  • Когда имя берётся из email, apiserver сам проверяет утверждение email_verified: при false токен отвергается, при отсутствии утверждения — принимается. Писать для этого своё правило не нужно.
  • claimValidationRules — дополнительные проверки на CEL над утверждениями токена; здесь пускаем только адреса своего домена.
  • Префикс oidc: у имён и групп — защита от совпадений: группа admins из провайдера не превратится случайно в какую-то внутреннюю, а пользователь с адресом, похожим на system:…, станет oidc:system:… и привилегий системного имени не получит. Есть и userValidationRules — проверки на CEL уже итогового имени и групп; например, правило !user.username.startsWith('system:') нужно тем, кто обходится без префикса.
  • Блок anonymous заодно сужает анонимный доступ до трёх путей проверки здоровья: /version без удостоверения теперь отвечает 401.

Клиенту apiserver о причине отказа ничего не сообщает — только Unauthorized. Причина — в журнале apiserver:

"Unable to authenticate the request" err="[invalid bearer token, oidc: email not verified]"
"Unable to authenticate the request" err="[invalid bearer token, oidc: verify token: oidc: expected audience \"kubernetes\" got [\"grafana\"]]"
"Unable to authenticate the request" err="[invalid bearer token, oidc: error evaluating claim validation expression: validation expression 'claims.email.endsWith('@example.com')' failed: разрешены только адреса @example.com]"

Изменённый файл apiserver подхватывает примерно за 15 секунд и пишет в журнал reloaded authentication config. Если в новом файле ошибка (например, незакрытая скобка в выражении CEL), он остаётся на прежней конфигурации, пишет failed to validate authentication config и увеличивает метрику apiserver_authentication_config_controller_automatic_reloads_total{status="failure"}. Но при старте apiserver с ошибочным файлом не запустится, так что проверять файл стоит до перезапуска.

В kubeadm файл подключается через ClusterConfiguration:

apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
apiServer:
  extraArgs:
    - name: authentication-config
      value: /etc/kubernetes/auth/authentication-config.yaml
  extraVolumes:
    - name: auth-config
      hostPath: /etc/kubernetes/auth
      mountPath: /etc/kubernetes/auth
      readOnly: true
      pathType: Directory

На новом кластере это часть конфигурации kubeadm init. На работающем те же поля дописывают в ConfigMap kubeadm-config в kube-system (из него kubeadm берёт настройки при обновлении, иначе флаг пропадёт при перегенерации манифеста) и создают манифест apiserver заново командой kubeadm init phase control-plane apiserver --config <файл>; с --dry-run она пишет манифест во временный каталог, и его можно сначала сравнить с текущим.

Сам kubectl в провайдер не ходит. Токен ему выдаёт exec-плагин — внешняя программа, которую kubectl вызывает перед запросом и которая печатает токен в формате ExecCredential. Для OIDC это обычно kubelogin (kubectl oidc-login): он открывает браузер, проводит вход, кеширует и обновляет токен.


kubeconfig

Файл kubeconfig (по умолчанию ~/.kube/config) связывает три списка:

apiVersion: v1
kind: Config
clusters:                       # куда подключаться
  - name: kubernetes
    cluster:
      server: https://k8s-api.example.com:6443
      certificate-authority-data: LS0tLS1CRUdJTi…   # CA, которому верить при проверке apiserver
users:                          # чем представляться
  - name: alice-cert
    user:
      client-certificate-data: LS0tLS1CRUdJTi…
      client-key-data: LS0tLS1CRUdJTi…
  - name: alice-oidc
    user:
      exec:
        apiVersion: client.authentication.k8s.io/v1
        command: kubectl
        args:
          - oidc-login
          - get-token
          - --oidc-issuer-url=https://sso.example.com/realms/infra
          - --oidc-client-id=kubernetes
        interactiveMode: IfAvailable
contexts:                       # пара «кластер + пользователь» и неймспейс по умолчанию
  - name: lab-alice
    context:
      cluster: kubernetes
      user: alice-oidc
      namespace: demo
current-context: lab-alice

Кластер называется kubernetes, как в admin.conf от kubeadm. Контекст — просто удобное имя для пары; переключение — kubectl config use-context. Пользователь в kubeconfig — это не «пользователь кластера», а способ получить удостоверение; имя alice-cert видно только локально, apiserver узнает имя из CN.

Файл с client-key-data — это пароль: права 600, не в Git, не в общих каталогах. Файл с exec-плагином секретов не содержит, и его можно раздавать.


Выдача доступа администратору сертификатом

Иногда без сертификата не обойтись: провайдера удостоверений нет, нужен аварийный доступ, или это служебная учётная запись внешней системы. Подписать сертификат можно руками ключом CA, но правильнее — через API: объект CertificateSigningRequest (CSR), который одобряет администратор, а подписывает kube-controller-manager тем же CA. Закрытый ключ при этом не покидает машину пользователя.

$ openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
    -keyout alice.key -out alice.csr -subj "/CN=alice/O=platform-admins"
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: alice
spec:
  request: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0K…   # base64 -w0 alice.csr
  signerName: kubernetes.io/kube-apiserver-client
  expirationSeconds: 604800        # неделя
  usages:
    - client auth

signerName: kubernetes.io/kube-apiserver-client — встроенный подписчик клиентских сертификатов для apiserver. expirationSeconds — желаемый срок; без него используется значение флага --cluster-signing-duration контроллера, по умолчанию год.

Почему срок так важен: выданный сертификат нельзя отозвать. apiserver не проверяет списки отзыва (CRL) и не спрашивает OCSP — сервис онлайн-проверки статуса сертификата. Сертификат действует, пока не истёк срок или пока CA не заменён. Что остаётся, если ключ alice утёк:

  • удалить RoleBinding и ClusterRoleBinding на пользователя alice — но если права выданы группе platform-admins, а она прописана в самом сертификате, придётся отбирать права у всей группы;
  • заменить CA кластера — с перевыпуском всех сертификатов всех компонентов и всех kubeconfig;
  • ждать истечения срока.

Отсюда практика: людям — OIDC; сертификаты — только служебным и аварийным учётным записям, с коротким сроком и правами через RoleBinding на имя пользователя, а не на группу из сертификата. И никогда — O=system:masters.


Авторизация: RBAC

После аутентификации запрос описывается атрибутами: пользователь и группы, глагол (get, list, create…), группа API, ресурс и подресурс, неймспейс, имя объекта. Авторизаторы перечислены во флаге --authorization-mode; kubeadm ставит Node,RBAC. Node — специальный авторизатор для kubelet: узел может читать Secret и ConfigMap только тех подов, что запланированы на него, и менять статус только своего объекта Node. Всё остальное решает RBAC (role-based access control).

RBAC — четыре типа объектов группы rbac.authorization.k8s.io:

Объект Область Что делает
Role неймспейс набор правил для ресурсов этого неймспейса
ClusterRole кластер набор правил: для кластерных ресурсов (Node, PV, CRD), для URL вне ресурсов (/metrics) или как шаблон для многих неймспейсов
RoleBinding неймспейс выдаёт Role или ClusterRole субъектам — в пределах своего неймспейса
ClusterRoleBinding кластер выдаёт ClusterRole субъектам во всех неймспейсах

Правило — это apiGroups + resources + verbs, опционально resourceNames:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: hello-deployer
  namespace: demo
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: ["apps"]
    resources: ["deployments/scale"]
    verbs: ["patch", "update"]
  - apiGroups: [""]                   # "" — основная группа: pods, services, configmaps…
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["hello-config"]   # только этот объект
    verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-hello-deployer
  namespace: demo
subjects:
  - kind: ServiceAccount
    name: ci
    namespace: demo
  - kind: Group
    name: oidc:hello-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: hello-deployer
  apiGroup: rbac.authorization.k8s.io

Детали, которые важны на практике:

  • Только разрешения. Запретов в RBAC нет: права складываются из всех привязок, и «всё, кроме Secret» выразить нельзя — только перечислить всё, что можно.
  • Глаголы. Для ресурсов: get, list, watch, create, update, patch, delete, deletecollection. Особые: use (PodSecurityPolicy в прошлом, сейчас — некоторые CRD), bind и escalate (для ролей), impersonate, approve и sign (для CSR). list и watch отдают объекты целиком — право list на Secret равно праву читать их все.
  • Подресурсы — отдельные ресурсы для RBAC: pods/log, pods/exec, pods/portforward, pods/eviction, deployments/scale, services/proxy, greetings/status. Право на pods не даёт права на pods/exec.
  • resourceNames ограничивает правило конкретными объектами, но не работает с create (имени ещё нет) и с list/watch без фильтра по имени.
  • RoleBinding на ClusterRole — главный способ переиспользования: одна ClusterRole hello-deployer и по RoleBinding в каждом неймспейсе, где она нужна.
  • roleRef неизменяем: чтобы сменить роль, привязку удаляют и создают заново.

Встроенные роли и агрегация

В кластере уже есть роли для людей:

ClusterRole Что даёт
cluster-admin всё на всё; через ClusterRoleBinding — на весь кластер, через RoleBinding — полный контроль одного неймспейса
admin почти всё в неймспейсе, включая Role и RoleBinding внутри него; квоты (resourcequotas) только читает, сам неймспейс не меняет
edit чтение и запись большинства объектов, включая Secret, pods/exec и выпуск токенов любого ServiceAccount неймспейса; без ролей и привязок
view чтение большинства объектов, кроме Secret; без pods/exec

admin, edit и view — агрегированные роли: их правила не записаны в них самих, а собираются контроллером из всех ClusterRole с определёнными метками. Так CRD из прошлой части становится доступен держателям стандартных ролей без правки самих ролей:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: greetings-view
  labels:
    rbac.authorization.k8s.io/aggregate-to-view: "true"
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
    rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
  - apiGroups: ["demo.example.com"]
    resources: ["greetings"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: greetings-edit
  labels:
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
    rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
  - apiGroups: ["demo.example.com"]
    resources: ["greetings", "greetings/scale"]
    verbs: ["create", "update", "patch", "delete", "deletecollection"]

Хорошие операторы ставят такие роли сами; при оценке оператора (часть 28) стоит проверить, не добавляет ли он в view чтение чего-то чувствительного.

Помимо ролей для людей, есть десятки system:* ролей для компонентов (system:kube-scheduler, system:node, system:controller:*): на стенде из 84 ClusterRole 66 — системные. Их не трогают: apiserver восстанавливает их при каждом запуске.


Опасные права

RBAC выглядит так, будто права независимы. На деле некоторые из них — ступенька к другим, и выдача одного равносильна выдаче большего.

get, list, watch на secrets. Прямое чтение всех паролей, ключей и токенов неймспейса (или кластера, если через ClusterRoleBinding). list достаточно — get по имени не нужен.

create на pods (и на всё, что создаёт поды: deployments, jobs, cronjobs, daemonsets, statefulsets). Создатель пода выбирает, что в него смонтировать и под каким ServiceAccount его запустить. Значит, он может:

  • смонтировать любой Secret неймспейса томом и прочитать его — права на secrets ему не нужны, kubelet прочтёт Secret сам;
  • запустить под от любого ServiceAccount неймспейса и получить его токен — то есть все его права;
  • если Pod Security не запрещает, создать привилегированный под с hostPath: / и стать root на узле (часть 30).

Поэтому create pods в неймспейсе — фактически права всех ServiceAccount и доступ ко всем Secret этого неймспейса.

create на pods/exec, pods/attach. Выполнение команд в существующих подах — с их токенами, их файлами и их сетевым доступом. kubectl exec открывает соединение по WebSocket — протоколу, который начинается с обычного HTTP-запроса GET с заголовком Upgrade: websocket и после ответа 101 Switching Protocols превращает то же TCP-соединение в двусторонний канал для потоков ввода и вывода. Поэтому исторически роли, где на pods/exec выдан только get, фактически давали exec. С 1.35 (бета, включено по умолчанию) apiserver требует для такого подключения ещё и create. Пользователь bob, которому Role выдаёт get и list на pods и только get на pods/exec, получает отказ:

error: unable to upgrade connection: pods "ci-runner-8695fbd774-jdh5k" is forbidden: User "bob" cannot create resource "pods/exec" in API group "" in the namespace "demo"

Но роли с get на pods/exec всё равно стоит считать опасными: на кластерах старше 1.35 они дают exec.

escalate и bind. RBAC защищён от повышения прав: создать Role или RoleBinding можно, только если у вас уже есть все права, которые они выдают. Даже с правом создавать роли alice получит отказ, если в роли есть то, чего нет у неё самой:

$ kubectl -n demo create role sneaky --verb=get --resource=secrets
Error from server (Forbidden): roles.rbac.authorization.k8s.io "sneaky" is forbidden: user "alice" (groups=["platform-admins" "system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["secrets"], Verbs:["get"]}

escalate на roles/clusterroles снимает эту защиту при создании ролей, bind — при привязке ролей, прав которых у вас нет. Любой из них — это путь к cluster-admin.

impersonate на users, groups, serviceaccounts. Позволяет выполнять запросы от чужого имени (заголовки Impersonate-User, Impersonate-Group; в kubectl — --as, --as-group). Тот, кто может притвориться группой kubeadm:cluster-admins, — администратор. С 1.36 включена по умолчанию (бета) ограниченная имперсонация: глагол impersonate:user-info на ресурс users с resourceNames разрешает притворяться только названными пользователями, а глаголы вида impersonate-on:user-info:list на обычные ресурсы — делать от их имени только перечисленные действия:

rules:                                   # ClusterRole: кем можно притворяться
  - apiGroups: ["authentication.k8s.io"]
    resources: ["users"]
    resourceNames: ["bob"]
    verbs: ["impersonate:user-info"]
---
rules:                                   # Role в demo: что можно делать от его имени
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["impersonate-on:user-info:list"]
$ kubectl -n demo get pods --as=bob
NAME                         READY   STATUS    RESTARTS   AGE
ci-runner-8695fbd774-jdh5k   1/1     Running   0          9m
hello-6b8ccbff4c-jjdqz       1/1     Running   0          9m
$ kubectl -n demo delete pod ci-runner-8695fbd774-jdh5k --as=bob
Error from server (Forbidden): pods "ci-runner-8695fbd774-jdh5k" is forbidden: User "alice" cannot impersonate-on:user-info:delete resource "pods" in API group "" in the namespace "demo"
$ kubectl -n demo get pods --as=carol
Error from server (Forbidden): users.authentication.k8s.io "carol" is forbidden: User "alice" cannot impersonate:user-info resource "users" in API group "authentication.k8s.io" at the cluster scope

Запрос при этом проходит обе проверки: собственные права bob и разрешение alice действовать от его имени. Для ServiceAccount и узлов есть свои глаголы (impersonate:serviceaccount и т. п.). Старый глагол impersonate продолжает работать и даёт имперсонацию без ограничений.

nodes/proxy. Прямой доступ к API kubelet на узлах через apiserver — а это exec в любой под узла, чтение логов и метрик. В 1.36 стала стабильной детальная авторизация kubelet: для метрик, статистики и логов есть отдельные подресурсы nodes/metrics, nodes/stats, nodes/log, и системам мониторинга теперь можно не давать nodes/proxy. Но сам nodes/proxy остаётся равным root на всех узлах.

create на serviceaccounts/token. Выпуск токенов от имени любого ServiceAccount неймспейса — то же, что владение ими. Встроенная роль edit это право даёт.

update/patch на validatingwebhookconfigurations, mutatingwebhookconfigurations. Можно зарегистрировать вебхук, который будет получать все создаваемые объекты, включая Secret, и подменять их.

approve на certificatesigningrequests вместе с правом создавать CSR — выпуск себе клиентского сертификата с любым CN и O.

Отсюда правило ревью: в Role и ClusterRole не должно быть * ни в verbs, ни в resources, ни в apiGroups — кроме cluster-admin.


Минимальные права для приложения

Каждый неймспейс получает ServiceAccount default, и каждый под без явного serviceAccountName запускается от него. Сам по себе default прав не имеет, но его токен монтируется в каждый под. Если кто-то однажды выдаст права default («чтобы заработало»), их получат все поды неймспейса. А токен, лежащий в контейнере, — первое, что ищет атакующий, получивший выполнение кода в приложении.

Правильно:

  1. Приложению, которому API не нужен (а это почти все: hello, базы, веб-серверы), токен не монтировать:
apiVersion: v1
kind: ServiceAccount
metadata:
  name: hello
  namespace: demo
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  namespace: demo
spec:
  template:
    spec:
      serviceAccountName: hello
      automountServiceAccountToken: false
      …

Поле есть и у ServiceAccount, и у пода; значение пода важнее. Ставить его в обоих местах — защита от того, что кто-то поменяет одно из них.

  1. Приложению, которому API нужен (контроллер, оператор, CI) — отдельный ServiceAccount, Role вместо ClusterRole, если хватает одного неймспейса, только нужные глаголы и, где возможно, resourceNames.

  2. Внешней системе (CI-серверу вне кластера) — короткоживущие токены через kubectl create token с --duration и --audience по мере надобности, а лучше — федерация удостоверений: если CI умеет выдавать OIDC-токены своим заданиям, его можно прописать в AuthenticationConfiguration как ещё одного провайдера, и в Kubernetes не будет храниться ни одного долгоживущего секрета.


Смотрим руками

Для опытов в неймспейсе demo нужны два ServiceAccount и два Deployment: hello из прошлых частей, переведённый на ServiceAccount hello без токена (как в разделе выше), и ci-runner — busybox от ServiceAccount ci с токеном по умолчанию:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci
  namespace: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ci-runner
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ci-runner
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ci-runner
    spec:
      serviceAccountName: ci
      containers:
        - name: runner
          image: busybox:1.37
          command: ["sleep", "infinity"]

Кто я:

$ kubectl auth whoami
ATTRIBUTE                                           VALUE
Username                                            kubernetes-admin
Groups                                              [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=6ff9adf123f0ab47fe9d5bb2f450928eefcf619e3f52b22f46d3d85b4a5e92d2]

Команда спрашивает apiserver (ресурс SelfSubjectReview) и показывает, каким он видит текущее удостоверение — полезно, когда непонятно, какой из пользователей kubeconfig сработал. В Extra apiserver кладёт отпечаток SHA-256 клиентского сертификата: по нему запросы конкретного сертификата находятся в журнале аудита.

Анонимный доступ — без kubeconfig, просто curl:

$ curl -sk https://k8s-api.example.com:6443/livez
ok
$ curl -sk https://k8s-api.example.com:6443/api
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "forbidden: User \"system:anonymous\" cannot get path \"/api\"",
  "reason": "Forbidden",
  "details": {},
  "code": 403
}

/version тоже отвечает анонимно, а всё остальное — 403: имя system:anonymous apiserver присвоил, но RBAC ему ничего не дал.

Выдаём доступ alice через CSR:

$ kubectl apply -f alice-csr.yaml
certificatesigningrequest.certificates.k8s.io/alice created

$ kubectl get csr alice
NAME    AGE   SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
alice   0s    kubernetes.io/kube-apiserver-client   kubernetes-admin   7d                  Pending

$ kubectl certificate approve alice
certificatesigningrequest.certificates.k8s.io/alice approved

$ kubectl get csr alice
NAME    AGE   SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
alice   2s    kubernetes.io/kube-apiserver-client   kubernetes-admin   7d                  Approved,Issued

$ kubectl get csr alice -o jsonpath='{.status.certificate}' | base64 -d > alice.crt
$ openssl x509 -in alice.crt -noout -subject -issuer -dates
subject=O=platform-admins, CN=alice
issuer=CN=kubernetes
notBefore=Nov  1 19:54:51 2026 GMT
notAfter=Nov  8 19:54:51 2026 GMT

notBefore на пять минут раньше одобрения: подписчик сдвигает начало срока назад на случай расхождения часов. Добавляем пользователя и контекст в kubeconfig:

$ kubectl config set-credentials alice-cert --client-certificate=alice.crt --client-key=alice.key --embed-certs=true
User "alice-cert" set.
$ kubectl config set-context lab-alice-cert --cluster=kubernetes --user=alice-cert --namespace=demo
Context "lab-alice-cert" created.

$ kubectl --context lab-alice-cert get pods
Error from server (Forbidden): pods is forbidden: User "alice" cannot list resource "pods" in API group "" in the namespace "demo"
$ kubectl --context lab-alice-cert auth whoami
ATTRIBUTE                                           VALUE
Username                                            alice
Groups                                              [platform-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=6c543d4596112bd6ce44b45ad6ec15db737eee77ecd758a851f3b57b34d7dab4]

Аутентификация прошла — apiserver знает её имя и группы, auth whoami разрешён любому аутентифицированному, — но прав на поды нет. Выдаём view в demo на имя пользователя и проверяем от имени администратора через имперсонацию:

$ kubectl -n demo create rolebinding alice-view --clusterrole=view --user=alice
rolebinding.rbac.authorization.k8s.io/alice-view created

$ kubectl -n demo auth can-i list pods --as=alice
yes
$ kubectl -n demo auth can-i get secrets --as=alice
no
$ kubectl -n demo auth can-i create pods/exec --as=alice
no
$ kubectl auth can-i list pods --as=alice -n kube-system
no

auth can-i использует API SubjectAccessReview: apiserver прогоняет запрос через авторизаторы, ничего не выполняя. Полный список прав субъекта в неймспейсе:

$ kubectl -n demo auth can-i --list --as=system:serviceaccount:demo:hello
Resources                                       Non-Resource URLs                      Resource Names   Verbs
selfsubjectreviews.authentication.k8s.io        []                                     []               [create]
selfsubjectaccessreviews.authorization.k8s.io   []                                     []               [create]
selfsubjectrulesreviews.authorization.k8s.io    []                                     []               [create]
globalnetworkpolicies.projectcalico.org         []                                     []               [get list watch create update patch delete deletecollection]
networkpolicies.projectcalico.org               []                                     []               [get list watch create update patch delete deletecollection]
                                                [/.well-known/openid-configuration/]   []               [get]
                                                [/.well-known/openid-configuration]    []               [get]
                                                [/api/*]                               []               [get]
                                                [/api]                                 []               [get]
                                                [/apis/*]                              []               [get]
                                                [/apis]                                []               [get]
                                                [/healthz]                             []               [get]
                                                [/healthz]                             []               [get]
                                                [/livez]                               []               [get]
                                                [/livez]                               []               [get]
                                                [/openapi/*]                           []               [get]
                                                [/openapi]                             []               [get]
                                                [/openid/v1/jwks/]                     []               [get]
                                                [/openid/v1/jwks]                      []               [get]
                                                [/readyz]                              []               [get]
                                                [/readyz]                              []               [get]
                                                [/version/]                            []               [get]
                                                [/version/]                            []               [get]
                                                [/version]                             []               [get]
                                                [/version]                             []               [get]

У ServiceAccount hello только то, что есть у любого аутентифицированного: узнать о себе, прочитать список API и служебные URL. Пути повторяются, потому что их выдают две роли: system:public-info-viewer (всем, включая анонимов) и system:discovery (аутентифицированным). /.well-known/openid-configuration и /openid/v1/jwks — открытые ключи подписи токенов ServiceAccount: их всем ServiceAccount выдаёт роль system:service-account-issuer-discovery, чтобы внешние системы могли проверять токены подов.

Неожиданные строки — полные права на networkpolicies.projectcalico.org и globalnetworkpolicies.projectcalico.org. Их выдаёт всем аутентифицированным ClusterRoleBinding calico-tiered-policy-passthrough, а настоящую проверку делает сам Calico: политики Calico разложены по уровням (tiers), и его API-сервер требует ещё права на уровень. Без них отказ:

$ kubectl -n demo get networkpolicies.projectcalico.org --as=system:serviceaccount:demo:hello
Error from server (Forbidden): networkpolicies.projectcalico.org is forbidden: operation on Calico tiered policy is forbidden

Отсюда важное ограничение auth can-i: он показывает только решение RBAC в kube-apiserver. Агрегированные API (часть запросов kube-apiserver передаёт отдельным API-серверам, как calico-apiserver) могут проверять что-то ещё.

Токен ServiceAccount и его содержимое:

$ kubectl -n demo create token hello --duration=10m | cut -d. -f2 | base64 -d 2>/dev/null | jq
{
  "aud": [
    "https://kubernetes.default.svc.cluster.local"
  ],
  "exp": 1793563801,
  "iat": 1793563201,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "jti": "2588cc28-1335-4888-9a21-e9dec8cca842",
  "kubernetes.io": {
    "namespace": "demo",
    "serviceaccount": {
      "name": "hello",
      "uid": "fa0b64f7-c3a0-455d-b0c8-833e4c88b67e"
    }
  },
  "nbf": 1793563201,
  "sub": "system:serviceaccount:demo:hello"
}

Выпустить токен можно и ServiceAccount без автомонтирования: automountServiceAccountToken управляет только томом в поде. Короче 10 минут apiserver токен не выдаст:

$ kubectl -n demo create token hello --duration=5m
error: failed to create token: TokenRequest.authentication.k8s.io "" is invalid: spec.expirationSeconds: Invalid value: 300: may not specify a duration less than 10 minutes

А вот токен, который kubelet смонтировал в под ServiceAccount ci (у него автомонтирование не выключено), — с привязкой к поду и узлу:

$ kubectl -n demo exec deploy/ci-runner -- cat /var/run/secrets/kubernetes.io/serviceaccount/token \
    | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iat, exp, "kubernetes.io"}'
{
  "iat": 1793563172,
  "exp": 1825099172,
  "kubernetes.io": {
    "namespace": "demo",
    "node": {
      "name": "k8s-node-1",
      "uid": "60c90f02-4a49-4cce-b306-dcefb70c0b9f"
    },
    "pod": {
      "name": "ci-runner-8695fbd774-jdh5k",
      "uid": "054e17af-58e3-419e-b294-f3d716236fd3"
    },
    "serviceaccount": {
      "name": "ci",
      "uid": "8758de5d-da1d-479a-9e3a-2581c6c25214"
    },
    "warnafter": 1793566779
  }
}

exp ровно через год после iat (продление для старых клиентов), а warnafter — настоящий срок, 3607 секунд: если токен предъявлен после него, apiserver засчитает это в метрику устаревших токенов. Откуда взялся файл, видно в спецификации пода — это проецируемый том:

$ kubectl -n demo get pod -l app.kubernetes.io/name=ci-runner -o json | jq '.items[0].spec.volumes'
[
  {
    "name": "kube-api-access-q6ft5",
    "projected": {
      "defaultMode": 420,
      "sources": [
        {
          "serviceAccountToken": {
            "expirationSeconds": 3607,
            "path": "token"
          }
        },
        {
          "configMap": {
            "items": [
              {
                "key": "ca.crt",
                "path": "ca.crt"
              }
            ],
            "name": "kube-root-ca.crt"
          }
        },
        {
          "downwardAPI": {
            "items": [
              {
                "fieldRef": {
                  "apiVersion": "v1",
                  "fieldPath": "metadata.namespace"
                },
                "path": "namespace"
              }
            ]
          }
        }
      ]
    }
  }
]

У пода hello с automountServiceAccountToken: false такого тома нет вовсе — список томов пуст (а заглянуть внутрь контейнера не получится: в distroless-образе hello нет ни оболочки, ни ls):

$ kubectl -n demo get pod -l app.kubernetes.io/name=hello -o jsonpath='{.items[0].spec.volumes}'

И как устроена агрегация:

$ kubectl get clusterrole view -o jsonpath='{.aggregationRule}{"\n"}'
{"clusterRoleSelectors":[{"matchLabels":{"rbac.authorization.k8s.io/aggregate-to-view":"true"}}]}

$ kubectl get clusterrole view -o jsonpath='{range .rules[*]}{.apiGroups}{.resources}{"\n"}{end}' | grep demo
["demo.example.com"]["greetings"]

Правило из greetings-view появилось в view само — его дописал контроллер агрегации в kube-controller-manager. Самого CRD в кластере может и не быть: роли ссылаются на ресурс по имени.

Последнее: кто в кластере имеет опасные права. Встроенного отчёта нет, но перебор привязок к cluster-admin делается одной командой:

$ kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin") | "\(.metadata.name): \([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))"'
cluster-admin: Group/system:masters
kubeadm:cluster-admins: Group/kubeadm:cluster-admins

Если в этом списке появится ServiceAccount какого-нибудь чарта — это повод разобраться до того, как разбираться придётся после инцидента.

Уборка: kubectl delete csr alice, kubectl delete clusterrole greetings-view greetings-edit, пользователь и контекст alice-cert — kubectl config delete-user alice-cert и kubectl config delete-context lab-alice-cert.


Аутентификация и RBAC определяют, кто и что может сделать с объектами API. Но то, что окажется внутри пода, — от чьего имени процесс работает на узле, какие у него capabilities, может ли он смонтировать каталог узла, — RBAC не регулирует: право create pods даёт всё или ничего.


Источники


Дальше — безопасность подов: securityContext, запуск не от root, capabilities, seccomp и AppArmor, user namespaces, стандарты Pod Security и их применение через Pod Security Admission, и собственные правила допуска на CEL.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.