Вход в Kubernetes: как мы решили нерешаемое для AuditRadar

Почему в Kubernetes нет понятия «вход» — и как мы решили это для AuditRadar

Когда мы приступили к созданию страницы Logins для AuditRadar — проводника журналов аудита в реальном времени для OpenShift и Kubernetes — мы столкнулись с препятствием, которое заставило нас детально разобраться в том, как аутентификация реально устроена на каждой из платформ. То, что поначалу казалось задачей «просто добавить отслеживание входов», превратилось в настоящий урок об архитектурных различиях между OpenShift и ванильным Kubernetes.

Ниже — история того, что мы обнаружили и как в итоге построили определение сессий, работающее на обеих платформах.

Проблема: «Кто вошёл?» — не такой уж простой вопрос

AuditRadar собирает и отображает события аудита от API-сервера Kubernetes. Страница Events у нас уже работала и показывала в реальном времени каждое CREATE, DELETE, PATCH. Следующим логичным шагом было: показать, кто вошёл, откуда и когда.

На OpenShift это оказалось сравнительно просто. На Kubernetes же выяснилось, что задача в традиционном смысле архитектурно неразрешима — потому что в Kubernetes нет никакого понятия «вход» вообще.

OpenShift: настоящий OAuth-сервер

OpenShift поставляется со встроенным OAuth 2.0-сервером (oauth-openshift), работающим в пространстве имён (namespace) openshift-authentication. Все действия пользователя проходят через него:

  • Вы открываете веб-консоль → браузер перенаправляется на OAuth

  • Вы запускаете oc login → CLI выполняет обмен OAuth-токена

  • Вы используете сервисный аккаунт (service account) → выдаётся токен SA

Этот OAuth-поток создаёт реальные, поддающиеся аудиту события. В частности, когда пользователь входит в систему, OAuth-сервер создаёт ресурс oauthaccesstoken. При выходе он удаляется. Эти события попадают в журнал аудита openshift-apiserver со всеми подробностями.

Вот как выглядит событие входа в сыром журнале аудита:

{
  "verb": "create",
  "requestURI": "/apis/oauth.openshift.io/v1/oauthaccesstokens",
  "user": {
    "username": "system:serviceaccount:openshift-authentication:oauth-openshift"
  },
  "requestObject": {
    "userName": "kubeadmin",
    "clientName": "console"
  }
}

Поле clientName — настоящая находка: оно сообщает ровно то, каким способом пользователь прошёл аутентификацию:

clientName Метод

console

Веб-консоль (браузер)

openshift-challenging-client

oc login (CLI)

любое другое значение

API-токен

Проблема реального IP-адреса в OpenShift

Есть один подводный камень: стандартный Ingress OpenShift (HAProxy) работает в режиме TLS passthrough (be_tcp). Это означает, что HAProxy никогда не завершает TLS-соединение — он просто пробрасывает сырые TCP-пакеты к oauth-openshift. Результат: все события входа показывают внутренний IP-адрес HAProxy, а не реальный IP клиента.

Чтобы исправить это, мы настроили Nginx в качестве L7 reverse proxy перед OAuth-эндпоинтом с добавлением заголовка X-Forwarded-For и сконфигурировали IngressController с параметром forwardedHeaderPolicy: Append. Затем мы добавили в коллектор XFF-кэш, который сопоставляет записи журнала доступа HAProxy (где реальный IP есть) с событиями OAuth по временной метке.

Что мы фиксируем в OpenShift

✅ События входа (веб-консоль / CLI / API-токен)

✅ События выхода

✅ Неудачные попытки входа

✅ Реальный IP клиента (при наличии Nginx reverse proxy)

✅ Длительность сессии (вход → выход)

Kubernetes: понятия «вход» нет вовсе

В ванильном Kubernetes нет встроенного OAuth-сервера. Нет команды login, нет обмена токенами, нет понятия сессии. Аутентификация в Kubernetes работает через:

  • X.509-сертификаты клиентаsystem:admin в kubeconfig использует сертификат

  • Токены сервисных аккаунтов — поды используют JWT-токены, монтируемые в рантайме

  • OIDC — опциональная внешняя интеграция

  • Файлы статических токенов — устаревший механизм, применяется редко

Когда system:admin выполняет kubectl get pods, никакого события «входа» не возникает. Сертификат просто предъявляется с каждым запросом — как ключ, который вы держите в руке. Нет момента «входа в кластер»: вы либо постоянно предъявляете действительный сертификат, либо нет.

# Это НЕ вход — это просто API-вызов, аутентифицированный сертификатом
kubectl get pods -n audit-vision

В журнале аудита Kubernetes нет ни oauthaccesstokens, ни ресурса login, ни какого-либо понятия сессии.

Открытие: credential-id

Копаясь в журналах аудита k3s в попытке понять, как идентифицировать «сессии», мы обнаружили кое-что интересное в поле user.extra:

{
  "user": {
    "username": "system:admin",
    "groups": ["system:masters", "system:authenticated"],
    "extra": {
      "authentication.kubernetes.io/credential-id": [
        "X509SHA256=7095e7ebb6c0f08fb8c1a1151246adfd32bf243bcac72eddcd5e67ebd0ee33dd"
      ]
    }
  }
}

Поле credential-id — это SHA256-отпечаток X.509-сертификата, которым был аутентифицирован запрос. Он уникален для каждого сертификата, а значит — уникален для каждого контекста kubeconfig.

Это дало нам эвристику «входа»: первый API-запрос с новым credential-id = начало сессии.

Реализация

Мы добавили Extra map[string][]string в нашу структуру AuditUser, извлекаем credential-id в нормализаторе и строим in-memory кэш с ключом actor + ":" + credentialID:

func extractK8sSessionEvent(ne model.NormalizedEvent) (model.AuthEvent, bool) {
	if ne.ActorType != "human" {
		return model.AuthEvent{}, false
	}
	credID := ne.Annotations["authentication.kubernetes.io/credential-id"]
	if credID == "" {
		return model.AuthEvent{}, false
	}
	cacheKey := ne.Actor + ":" + credID
	// Видели этот credential за последние 8 часов? Это не новая сессия.
	if v, found := credentialCache.Load(cacheKey); found {
		if time.Since(v.(time.Time)) < 8*time.Hour {
			return model.AuthEvent{}, false
		}
	}
	credentialCache.Store(cacheKey, time.Now())
	return model.AuthEvent{
		Actor:     ne.Actor,
		Method:    detectLoginMethod(ne.UserAgent, ne.Source),
		SourceIP:  ne.SourceIP,
		EventType: "login",
		Success:   true,
	}, true
}

Функция detectLoginMethod разбирает User-Agent:

func detectLoginMethod(userAgent, source string) string {
	ua := strings.ToLower(userAgent)
	switch {
	case strings.Contains(ua, "kubectl/"):
		return "oc-cli" // kubectl
	case strings.Contains(ua, "mozilla/") || strings.Contains(ua, "chrome/"):
		return "web-console" // браузер (k8s dashboard)
	default:
		return "api-token" // скрипты, CI/CD
	}
}

Что мы фиксируем в Kubernetes

✅ «Начало сессии» (первый запрос с новым credential-id)

✅ Реальный IP клиента (kube-apiserver видит его напрямую — прокси не нужен!)

✅ Метод аутентификации (kubectl / браузер / API)

❌ Нет событий выхода (сертификаты не истекают в середине сессии)

❌ Нет неудачных попыток входа (неверные сертификаты отклоняются на уровне TCP до стадии аудита)

Примечательно, что в Kubernetes реальный IP-адрес доступен бесплатно — потому что kubectl подключается напрямую к kube-apiserver:6443, минуя какой-либо OAuth-прокси. Никакого Nginx, никакого HAProxy, никаких манипуляций с XFF не требуется.

Сравнение: фундаментальное различие

OpenShift Kubernetes

Механизм аутентификации

Встроенный OAuth 2.0-сервер

X.509-сертификаты / токены SA / OIDC

Событие входа

oauthaccesstokens CREATE

Аналога нет

Событие выхода

oauthaccesstokens DELETE

Аналога нет

Понятие сессии

Нативное

Эвристическое (credential-id)

Отслеживание реального IP

Требует L7-прокси + XFF

Бесплатно (прямой TCP до apiserver)

Неудачная аутентификация видна в аудите

Да (через лог oauth-server)

Нет (TLS отклоняет до аудита)

Определение метода

Поле clientName

Разбор User-Agent

Определение платформы в интерфейсе

Поскольку AuditRadar поддерживает обе платформы, мы добавили переменную окружения PLATFORM, которую читает UI для переключения тем и подписей. На развёртываниях OpenShift в заголовке отображается красный значок OCP; на Kubernetes — синий K8S. CSS-переменные переключаются соответственно:

/* По умолчанию: красный OpenShift */
:root { --accent: #ee0000; }
/* Kubernetes: синий */
body.k8s { --accent: #326CE5; --bg: #08101a; }

Это задаётся через значения Helm в соответствующих чартах:

# Helm/audit-radar-k8s/values.yaml
env:
  - name: PLATFORM
    value: kubernetes

Что это означает для команд безопасности

Если вы проводите аудит соответствия (SOC2, PCI-DSS) и вам нужно ответить на вопрос «кто входил в кластер и когда»:

На OpenShift — вы получаете чёткий журнал входов и выходов с временными метками, методом и (при правильной настройке прокси) реальными IP-адресами. Это напрямую соответствует традиционным требованиям к журналам доступа.

На Kubernetes — вам придётся принять тот факт, что «вход» здесь — это условность, а не реальное событие. Подход на основе credential-id даёт осмысленные границы сессий, однако событий неудачной аутентификации и сигналов о выходе нет. Для полноценного отслеживания пользователей отраслевой ответ — интеграция OIDC с внешним провайдером (Dex, Keycloak, Okta), но это отдельная настройка, которой большинство самоуправляемых кластеров из коробки не имеют.

Попробуйте сами

AuditRadar — проект с открытым исходным кодом: github.com/vsenatorov/auditvision

В репозитории включены Helm-чарты как для OpenShift, так и для Kubernetes. Страница Logins работает на обеих платформах — просто означает немного разные вещи на каждой из них.

Если вы работаете с k3s, k8s или OpenShift и хотите видеть в реальном времени, кто и что делает в вашем кластере — попробуйте.

Виктор Сенаторов — старший архитектор в Red Hat. Разрабатывает инструменты безопасности с открытым исходным кодом для Kubernetes и OpenShift.

© 2026 meganuke