iac/infrastructure/security-audit/README.md

62 KiB
Raw Blame History

security-audit

Поток ИБ в Kafka, четыре источника:

Источник Коллектор Топик
журнал аудита kube-apiserver (мастера) DaemonSet audit-collector security.k8s-audit
журнал доступа ingress-шлюза Istio Deployment access-collector, pipeline logs/access security.mesh-access
события Zitadel (Actions v2) Deployment access-collector, pipeline logs/idp security.idp-events
журнал аудита Vault sidecar audit-shipper в подах Vault → access-collector, pipeline logs/vault security.vault-audit

Одно сообщение — одна запись в JSON (encoding: raw, без OTLP-обёртки), обогащённая полями sarex_*. Дальнейшие этапы (processor → MinIO/Parquet) — отдельными компонентами.

С observability-коллектором (infrastructure/opentelemetry-collector) не связан: отдельный релиз, namespace и конфиг.

Каталог Что Namespace
base/ оба коллектора, NetworkPolicy, AuthorizationPolicy, Namespace security-audit
base/idp-actions-bootstrap/ Job настройки Target/Execution'ов в Zitadel, скрипт и список событий security-audit
mesh/ EnvoyFilter и Telemetry шлюза; без namespace в kustomization, селектор шлюза — заглушка istio-system
uralkal/ оверлей: base + mesh, селектор шлюза, Kafka, dependsOn —
../vault/uralkal-audit/ Vault-часть журнала аудита Vault: оверлей поверх vault/uralkal (не подключён) vault
Оверлей Статус
uralkal манифесты готовы, в clusters/uralkal не подключён

Журнал аудита kube-apiserver

DaemonSet audit-collector-agent на узлах node-role.kubernetes.io/control-plane. Он читает /var/log/audit/kube-apiserver-audit*.log (текущий и ротированные файлы), позиции хранит в /var/lib/otelcol-audit (hostPath), туда же складывает дисковую очередь отправки.

Обработка (transform/audit):

  • вырезание шума: metadata.managedFields и аннотация kubectl.kubernetes.io/last-applied-configuration в requestObject / responseObject;
  • маскирование: значения параметров password|passwd|pwd|token|secret|api_key|api-key в requestURI (token=abc → token=***), сама команда exec не маскируется;
  • классификация — поля ниже. Правила применяются по порядку, последнее подходящее побеждает.
Поле Значение Условие
sarex_contour имя контура env CONTOUR из оверлея
sarex_dry_run true / false requestURI содержит dryRun=All
sarex_action read verb ∈ get, list
mutate verb ∈ create, update, patch, delete, deletecollection
status objectRef.subresource == status
interactive objectRef.subresource ∈ exec, attach, portforward
sarex_outcome applied код 2xx, не dry-run
dry_run код 2xx, dry-run
denied_authn 401
denied_authz 403 и authorization.k8s.io/decision=forbid
denied_admission 4xx при decision=allow, кроме 404/409/422
invalid 422
conflict 409
not_found 404
error 5xx
sarex_actor_type human по умолчанию
serviceaccount system:serviceaccount:*
controller system:serviceaccount:kube-system:*, system:kube-*, system:apiserver
node system:node:*
gitops system:serviceaccount:flux-system:*

watch не попадает ни в одну категорию sarex_action: поле не выставляется.

Надёжность доставки: required_acks: -1, дисковая очередь (sending_queue.storage), бесконечные ретраи. При заполненной очереди срабатывает backpressure на filelog, чтение файла приостанавливается. Данные теряются, только если файл успеет уйти из ротации (10 × 100 МБ) раньше, чем Kafka вернётся.

Предусловия подключения в контуре

  1. Аудит apiserver включён (INFRA-3057): флаги --audit-policy-file, --audit-log-path=/var/log/audit/kube-apiserver-audit.log, --audit-log-maxsize=100, --audit-log-maxbackup=10. Каталог /var/log/audit есть на всех мастерах.

  2. Kafka: топик и SCRAM-пользователь.

    • Топик security.k8s-audit: 1 партиция, retention.ms=259200000 (3 суток), max.message.bytes=5242880, RF по контуру (в uralkal один controller → RF 1).
    • Пользователь security-audit (SCRAM-SHA-512). ACL не нужны.

    Заводится terraform'ом: infra/terraform, ветка contour, модуль kafka-topics-incluster, блок environments.<контур> в infrastructure.yaml. Если нужно вручную — из пода контроллера, через внутренний листенер (INTERNAL, 9094, SASL_PLAINTEXT/PLAIN, inter_broker_user):

    kubectl -n kafka exec -it kafka-kafka-contour-controller-0 -c kafka -- bash
    
    cat > /tmp/admin.properties <<EOF
    security.protocol=SASL_PLAINTEXT
    sasl.mechanism=PLAIN
    sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required username="inter_broker_user" password="$(cat /vault/secrets/kafka-inter-broker-password)";
    EOF
    B=localhost:9094
    
    kafka-topics.sh --bootstrap-server $B --command-config /tmp/admin.properties --create \
      --topic security.k8s-audit --partitions 1 --replication-factor 1 \
      --config retention.ms=259200000 --config max.message.bytes=5242880
    
    kafka-configs.sh --bootstrap-server $B --command-config /tmp/admin.properties --alter \
      --entity-type users --entity-name security-audit \
      --add-config 'SCRAM-SHA-512=[iterations=4096,password=<пароль>]'
    
    rm /tmp/admin.properties
    
  3. Vault (secrets/ — KV v2):

    • секрет secrets/data/security-audit/kafka с ключами username, password, ca_crt; ca_crt — содержимое ca.crt из Secret kafka/kafka-kafka-contour-tls (CA генерирует чарт kafka-contour, срок 365 дней; при перевыпуске обновить и здесь);
    • политика read на secrets/data/security-audit/kafka;
    • роль security-audit в auth/kubernetes: bound SA audit-collector и access-collector, namespace security-audit, политика выше.

    Заводится в infra/terraform, ветка contour, модуль vault-platform-secrets (роли vault_kubernetes_auth_backend_role, KV-секреты).

  4. Образ cr.yandex/crp3ccidau046kdj8g9q/opentelemetry-collector/opentelemetry-collector-contrib:0.121.0-amd64 доступен в registry контура (тот же, что дефолт чарта opentelemetry-collector-prod 0.117.1), regcred есть в namespace security-audit.

Подключение

Отдельным MR после выполнения предусловий — добавить в clusters/uralkal/kustomization.yaml:

  - ../../infrastructure/security-audit/uralkal

dependsOn в оверлее ждёт HelmRelease kafka/kafka и vault/vault.

Порядок: сначала должен раскататься провайдер audit-access в istiod (infrastructure/istio-pilot/uralkal/istio-pilot.yaml, применяется сразу, как только попадёт в master: кластер его уже подключает). Telemetry из mesh/ ссылается на этот провайдер по имени. Проверка: kubectl -n istio-system get cm istio -o jsonpath='{.data.mesh}' | grep -A2 audit-access.

Проверка после подключения

  • DaemonSet security-audit/audit-collector-agent только на мастерах, все поды Ready: kubectl -n security-audit get pods -o wide.
  • В логах коллектора нет ошибок: kubectl -n security-audit logs ds/audit-collector-agent -c opentelemetry-collector-prod.
  • События в топике (из пода контроллера Kafka, с admin.properties выше): kafka-console-consumer.sh --bootstrap-server localhost:9094 --consumer.config /tmp/admin.properties --topic security.k8s-audit --max-messages 5.
  • kubectl create secret generic audit-probe --from-literal=k=v → событие с objectRef.resource=secrets уровня Metadata, без requestObject/responseObject.
  • Рестарт пода коллектора (kubectl delete pod) → нет дублей и пропусков: сверить auditID в топике с файлом журнала на мастере за интервал рестарта.

Известное ограничение

Значения env с паролями внутри requestObject / responseObject workload-ресурсов на уровне RequestResponse коллектор не маскирует: OTTL плохо работает с массивами (spec.containers[].env[]). Маскирование будет в processor'е. До его готовности не повышать уровень для workload-ресурсов выше Request в контурах, где пароли могут быть в env.

Журнал доступа шлюза

Каждый внешний HTTP-запрос через ingress-шлюз (istio-system, istio: ingressgateway): кто, что, когда, результат, IP, x-request-id, trace_id. Поток ИБ отдельно от прикладных журналов (п.20); IP источника для событий приложений находится по request_id (п.15).

ingress-шлюз (Envoy)
  └─ Telemetry gateway-audit-access ─► провайдер audit-access (envoyOtelAls, meshConfig istiod)
       └─ OTLP gRPC, mTLS ─► access-collector.security-audit:4317 (2 реплики, sidecar Istio)
            └─ transform/access ─► Kafka security.mesh-access ─► processor (infra/k8s-audit, отдельная задача)

Поля записи

Поле Источник Envoy Примечание
sarex_kind — всегда mesh_access
start_time %START_TIME%
request_id %REQ(X-REQUEST-ID)% ключ связи с журналами приложений (п.15)
trace_id %TRACE_ID% пусто, пока в mesh выключен трейсинг
authority, method, protocol :authority, :method, %PROTOCOL%
path %REQ_WITHOUT_QUERY(:PATH)% без query; маскирование — ниже
response_code, response_flags %RESPONSE_CODE%, %RESPONSE_FLAGS% 0 — ответа не было
duration_ms, bytes_in, bytes_out %DURATION%, %BYTES_RECEIVED%, %BYTES_SENT%
x_forwarded_for, downstream_ip X-Forwarded-For, %DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT% за NodePort — см. открытые вопросы
user_sub, user_org x-sarex-audit-sub, x-sarex-audit-org заполняет RequestAuthentication из JWT; пока её нет — пусто
user_agent, route, upstream_cluster User-Agent, %ROUTE_NAME%, %UPSTREAM_CLUSTER%
sarex_contour env CONTOUR
sarex_action по method read: GET/HEAD/OPTIONS; mutate: POST/PUT/PATCH/DELETE
sarex_outcome по response_code applied 2xx–3xx, denied_authn 401, denied_authz 403, invalid прочие 4xx (включая 404), error 5xx и 0
sarex_actor_type по user_sub human, если есть, иначе anonymous

Не логируется (п.23):

  • Authorization, Cookie, Set-Cookie — это сами учётные данные: журнал с ними позволяет войти от имени пользователя;
  • query-строка — в ней бывают токены публичных ссылок, token=, подписи S3 (X-Amz-Signature).

Маскирование в пути:

  • сегмент после token|secret|key|signature/ → ***;
  • JWT-подобные строки → ***jwt***.

Защита от подмены: EnvoyFilter strip-audit-headers первым HTTP-фильтром удаляет входящие x-sarex-audit-sub/org. Без него клиент мог бы записать в журнал любого пользователя.

Пользователь в журнале: RequestAuthentication — не создана (блокер)

user_sub/user_org должна заполнять RequestAuthentication на шлюзе (outputClaimToHeaders). Но она отклоняет любой запрос с JWT издателя, которого нет в jwtRules (Jwt issuer is not configured, 401), на всех хостах шлюза. А издатели в Уралкалии подтверждаются по репозиторию не все:

Издатель Заголовок issuer / JWKS Статус
Zitadel Authorization: Bearer, Identity https://login.sarex.local.uralkali.com (ExternalDomain + ExternalSecure: true в infrastructure/zitadel/uralkal; ExternalPort не задан — проверить, что в /.well-known/openid-configuration нет :8080) issuer — TODO(verify); префикс заголовка Identity не подтверждён
django (SimpleJWT, RS512) Authorization: Bearer (режим sarex) в коде SIMPLE_JWT_ISSUER по умолчанию default_issuer (apps/django/base/django-configmap.yaml), в CONFIGURATION.md — django; в uralkal не задан. Ключ — secrets/data/vault/common/rsa_keys, в репозитории нет не подтверждён
Camunda Keycloak Authorization: Bearer на camunda-* https://camunda-keycloak.sarex.local.uralkali.com/auth/realms/camunda-platform (infrastructure/camunda/uralkal), JWKS …/protocol/openid-connect/certs подтверждён
document-link Authorization: Bearer (зашитый/сервисный JWT) неизвестен не подтверждён

Пока таблица не закрыта, журнал пишется без пользователя (sarex_actor_type=anonymous). Пользователя для события можно найти по request_id в журналах приложений.

JWKS Zitadel — выбранный вариант, когда дойдёт до включения:

  • inline jwks — нет: Zitadel ротирует ключи подписи по умолчанию раз в часы (KeyConfig.PrivateKeyLifetime 6h), inline-набор устареет за день, и шлюз начнёт отдавать 401 всем.
  • jwksUri на внешний адрес, загрузка istiod — только если подтверждено: istiod резолвит login.sarex.local.uralkali.com и доверяет CA сертификата uralkal-sarex-tls. В istiod нет CA заказчика, и отключать проверку (JWKS_RESOLVER_INSECURE_SKIP_VERIFY) нельзя.
  • Рекомендуется: PILOT_JWT_ENABLE_REMOTE_JWKS=envoy (env istiod) и jwksUri: http://login.sarex.local.uralkali.com:8080/oauth/v2/keys плюс ServiceEntry хоста login.sarex.local.uralkali.com:8080 (HTTP, resolution: DNS, endpoint zitadel-idp-contour.zitadel.svc.cluster.local). Ключи забирает Envoy шлюза через кластер mesh, без внешнего DNS и CA. Host остаётся внешним доменом инстанса, его Zitadel узнаёт. Прямой http://zitadel-idp-contour.zitadel.svc:8080/... Zitadel отклонит: инстанс определяется по Host.

Черновик (не подключён; включать после закрытия таблицы и согласования 401 с владельцами приложений):

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata: {name: audit-jwt, namespace: istio-system}
spec:
  selector: {matchLabels: {istio: ingressgateway}}
  jwtRules:
    - issuer: https://login.sarex.local.uralkali.com                 # TODO(verify)
      jwksUri: http://login.sarex.local.uralkali.com:8080/oauth/v2/keys
      forwardOriginalToken: true
      fromHeaders:
        - {name: Authorization, prefix: "Bearer "}
        - {name: Identity, prefix: "Bearer "}                         # TODO(verify): префикс
      outputClaimToHeaders:
        - {header: x-sarex-audit-sub, claim: sub}
        - {header: x-sarex-audit-org, claim: "urn:zitadel:iam:user:resourceowner:id"}   # TODO(verify): есть ли claim в access token
    - issuer: https://camunda-keycloak.sarex.local.uralkali.com/auth/realms/camunda-platform
      jwksUri: http://camunda-keycloak.camunda.svc.cluster.local/auth/realms/camunda-platform/protocol/openid-connect/certs   # TODO(verify)
      forwardOriginalToken: true
    # - issuer: <django SIMPLE_JWT_ISSUER>   # блокер: issuer и публичный ключ

Предусловия подключения

  • Топик security.mesh-access: 3 партиции, retention.ms=259200000, max.message.bytes=1048576, RF по контуру — тем же путём, что security.k8s-audit (terraform или команды выше). Пользователь — тот же security-audit, ACL не нужны.
  • Роль Vault security-audit: в bound SA добавлен access-collector (см. предусловия выше).
  • Провайдер audit-access раскатан в istiod (см. «Подключение»).

Sidecar и Vault

  • Namespace security-audit без istio-injection: sidecar получают только поды с меткой sidecar.istio.io/inject=true (webhook object.sidecar-injector.istio.io). Это access-collector. У audit-collector метка "false". Метка istio-injection: disabled отключила бы инъекцию совсем, даже по метке пода.
  • PeerAuthentication в репозитории нет, поэтому режим PERMISSIVE. Шлюз шлёт в коллектор по auto-mTLS (оба в mesh), коллектор в Kafka — тоже через mTLS (kafka в mesh), внутри остаётся SASL_SSL.
  • Vault Agent (agent-init-first, pre-populate) + istio-proxy: по паттерну контура (apps/bi, apps/bim, apps/cde…) traffic.sidecar.istio.io/excludeOutboundPorts: "8200". Init-агент ходит в Vault мимо iptables-перехвата, даже если istio-init отработал раньше.
  • holdApplicationUntilProxyStarts: kafka exporter подключается к брокерам на старте и без готового proxy падает.
  • istio-init требует NET_ADMIN/NET_RAW: enforce privileged это разрешает, профиль warn: baseline даст предупреждения при создании подов — ожидаемо.

Проверка после подключения

  • Провайдер и порядок фильтров на шлюзе:
    POD=$(kubectl -n istio-system get pod -l istio=ingressgateway -o jsonpath='{.items[0].metadata.name}')
    istioctl -n istio-system proxy-config cluster $POD | grep access-collector
    istioctl -n istio-system proxy-config listener $POD -o json \
      | jq -r '.[].filterChains[].filters[] | select(.name=="envoy.filters.network.http_connection_manager")
               | .typedConfig.httpFilters[].name' | uniq
    
    Первым в списке — security-audit.strip-audit-headers, раньше envoy.filters.http.jwt_authn (когда появится). В выводе listener есть envoy.access_loggers.open_telemetry.
  • curl -H 'x-sarex-audit-sub: spoof' https://sarex.local.uralkali.com/... → в Kafka user_sub пустой.
  • Запрос с ?token=canary → строки canary нет в топике.
  • После включения RequestAuthentication:
    • запрос с валидным токеном → user_sub заполнен;
    • с невалидным токеном Zitadel → 401 (так работает RequestAuthentication). Согласовать с владельцами приложений до включения.
  • kubectl -n security-audit scale deploy/access-collector --replicas=0 → трафик шлюза не падает (ALS асинхронный). Потери видны в статистике Envoy: kubectl -n istio-system exec $POD -- pilot-agent request GET stats | grep access_logs. Вернуть 2 реплики.
  • Чтение топика: как для security.k8s-audit, --topic security.mesh-access.

Мониторинг

  • otelcol_receiver_accepted_log_records{receiver="otlp"} у access-collector: алерт, если 0 за 10 минут при живом трафике шлюза (istio_requests_total на ingressgateway).
  • otelcol_exporter_sent_log_records / otelcol_exporter_send_failed_log_records (exporter="kafka/access"), otelcol_exporter_queue_size против otelcol_exporter_queue_capacity, otelcol_exporter_enqueue_failed_log_records.
  • Метрики коллектора отдаются на :8888 пода. Prometheus в Уралкалии по репозиторию не найден — сбор метрик отдельно.

Открытые вопросы

  • IP клиента (п.15). Шлюз — одна реплика на control-plane за Service NodePort (30080/30443) без externalTrafficPolicy, т. е. Cluster. kube-proxy делает SNAT, и в downstream_ip попадает IP узла, а не клиента. Нужно выяснить, есть ли перед NodePort балансировщик заказчика и передаёт ли он X-Forwarded-For. Если да — meshConfig.defaultConfig.gatewayTopology.numTrustedProxies (или аннотация proxy.istio.io/config на шлюзе) по числу прокси, иначе Envoy не доверяет XFF. Если балансировщика нет — externalTrafficPolicy: Local и DNS/LB только на узел шлюза. Ничего из этого не меняли.
  • Статика (*/static/*, фронтенды) пишется в журнал. Исключать ли — решение ИБ (фильтр в mesh/telemetry.yaml).
  • Запросы между сервисами (sidecar'ы приложений) — отдельный Telemetry с фильтром, не в этой задаче.
  • Очередь в памяти: queue_size: 50000 считается в запросах (батчах до 2000 записей), а не в записях. При долгой недоступности Kafka упрётся в memory_limiter/лимит 512Mi раньше, чем в queue_size. Записи сверх этого теряются, шлюз не блокируется.

События Zitadel

Вход, неуспешный вход, блокировки, изменения пользователей, ролей и members (п.14–16 документа ИБ).

Zitadel (Actions v2: Execution с условием event → Target restAsync, JSON по HTTP)
  └─ http://access-collector.security-audit.svc.cluster.local:8088/zitadel/events  (mTLS, sidecar у обоих)
       └─ webhookevent/zitadel ─► transform/idp ─► Kafka security.idp-events ─► processor (infra/k8s-audit, idp/)

Target и Execution'ы заводит Job idp-actions-bootstrap (base/idp-actions-bootstrap/) через API Zitadel. Список событий — files/events.txt.

Почему без HMAC

Zitadel подписывает запросы (X-ZITADEL-Signature, HMAC-SHA256 тела), но webhookevent подпись проверять не умеет. Вместо подписи источник подтверждается на транспортном уровне:

  • mTLS Istio: у Zitadel и коллектора есть sidecar, identity источника — SA zitadel;
  • AuthorizationPolicy access-collector: на 8088 только POST /zitadel/events от cluster.local/ns/zitadel/sa/zitadel, на 4317 только из istio-system, остальное запрещено (ALLOW-политика закрывает всё, что не разрешено);
  • NetworkPolicy: 8088 только из namespace zitadel от подов app.kubernetes.io/name=idp-contour,component=start.

Условие: Zitadel в mesh. Сейчас это так: ns zitadel с istio-injection: enabled, у Deployment метка sidecar.istio.io/inject: "true" (чарт idp-contour 4.12.13). Если Zitadel выведут из mesh, у его запросов не будет principal, и коллектор начнёт отвечать 403. Тогда либо вернуть sidecar, либо перейти на приёмник с проверкой HMAC (signingKey из CreateTarget — во Vault).

Probe'ы kubelet AuthorizationPolicy не блокирует: при rewriteAppHTTPProbers (по умолчанию) sidecar переписывает HTTP-probe на pilot-agent :15020, а тот ходит в приложение напрямую, мимо Envoy и RBAC-фильтра. При подключении сбора метрик коллектора (:8888) нужно отдельное правило в политике.

Поля события

Тело запроса Zitadel — ContextInfoEvent (internal/repository/execution/queue.go, v4.12.2), все поля omitempty.

Поле Откуда Примечание
event_type Zitadel например, user.human.password.check.failed
created_at Zitadel RFC3339Nano
aggregateID, aggregateType Zitadel объект события (пользователь, грант, организация…)
resourceOwner, instanceID Zitadel организация и инстанс
userID Zitadel кто выполнил действие; нет — системное событие
sequence, version Zitadel
details.user_name event_payload.userName только user.human.added, user.human.selfregistered
details.role_keys, details.project_id event_payload.roleKeys, .projectId только user.grant.*
sarex_kind — idp_event
sarex_contour env CONTOUR
sarex_outcome по event_type denied_authn для *.check.failed, иначе applied
sarex_actor_type по userID human, без userID — system

event_payload удаляется целиком (п.23). В payload бывают хеш пароля (user.human.password.changed), секреты OTP, client secret, ПДн. Allowlist безопаснее denylist'а: новое поле в новой версии Zitadel не уйдёт в Kafka само. Нужные ИБ поля копируются в details явно.

  • TODO(verify): allowlist стартовый. Уточнить по реальным событиям на стенде, какие ещё поля нужны processor'у.

Тело, которое не разобралось как JSON, заменяется маркером {"sarex_kind": "idp_event", "sarex_error": "unparsed"}: сырой текст мог бы унести payload. В 4.12.2 event_payload — объект (json.RawMessage); вариант с base64-строкой тоже поддержан.

Job idp-actions-bootstrap

  • Образ. В registry контура нет образа с curl и jq вместе. init-контейнер из contour/zitadel/kubectl:1.33.4 (уже нужен setup-Job'у Zitadel) копирует jq 1.6 с библиотеками в emptyDir. Скрипт идёт в contour/kafka-nn/kafka:4.0.0-debian-12-r10 (curl 7.88). Оба на Debian 12. TODO(verify): заменить на один toolbox-образ.

  • Инстанс Zitadel выбирается заголовком x-zitadel-instance-host (+ x-zitadel-public-host, X-Forwarded-Proto: https). Это InstanceHostHeaders/PublicHostHeaders из defaults.yaml v4.12.2. Host остаётся именем Service: при подмене Host на внешний домен istio-proxy не нашёл бы маршрут и отправил бы запрос в passthrough, без mTLS.

  • Mesh. Job в mesh (sidecar.istio.io/inject=true, holdApplicationUntilProxyStarts). В конце скрипт вызывает POST 127.0.0.1:15020/quitquitquit, istio-proxy завершается, под — Completed. STRICT mTLS у Zitadel сейчас нет, но Job к нему готов.

  • Vault: роль security-audit-bootstrap, secrets/data/security-audit/zitadel-bootstrap → pat → /vault/secrets/zitadel-pat; excludeOutboundPorts: 8200 — паттерн контура.

  • Скрипт (files/idp-actions-bootstrap.sh, POSIX sh) идемпотентен:

    1. ждёт /debug/ready (10 попыток × 15 с);
    2. печатает все типы событий инстанса (known: …, POST /admin/v1/events/types/_search);
    3. ищет Target security-audit-idp-events по имени и создаёт или обновляет его (restAsync, endpoint, timeout: 10s);
    4. для каждого события — PUT /v2/actions/executions с условием {"event": {"event": "<тип>"}}. Чужие targets Execution'а сохраняются, свой добавляется в конец;
    5. неизвестное имя события — ошибка: остальные события обрабатываются, код выхода ≠ 0.

    PAT не печатается (set -x нет, заголовки — в файле в tmpfs), signingKey из ответов не выводится.

  • Перезапуск. ConfigMap'ы скрипта и событий — из configMapGenerator (хеш в имени). Правка любого файла меняет spec Job'а, и Flux пересоздаёт его (kustomize.toolkit.fluxcd.io/force: enabled). После ttlSecondsAfterFinished (сутки) Job удаляется, и Flux создаёт его заново: раз в сутки идёт сверка, повторный запуск ничего не меняет.

  • Порядок. У Job нет dependsOn: он стартует вместе с HelmRelease'ами. Если Zitadel ещё не готов — до 10 попыток, дальше backoffLimit: 3. Execution'ы ссылаются на Target по id, коллектор может подняться позже: до его готовности события теряются (повторов нет, см. риски). Подключать модуль, когда Zitadel уже работает.

Предусловия подключения

  • Топик security.idp-events: 3 партиции, retention.ms=259200000, RF по контуру, тем же путём, что остальные топики. Пользователь security-audit, ACL не нужны.
  • Сервисный пользователь Zitadel security-audit-bootstrap (machine user) с ролью IAM_OWNER на инстансе. Нужны action.target.read/write/delete, action.execution.read/write, events.read. Его PAT — во Vault secrets/data/security-audit/zitadel-bootstrap, ключ pat.
  • Роль Vault security-audit-bootstrap в auth/kubernetes: bound SA idp-actions-bootstrap, namespace security-audit, политика read на secrets/data/security-audit/zitadel-bootstrap.
  • Feature flag не нужен: в v4.12.2 actions в /v2/features/instance удалён (reserved), Actions v2 включены всегда.

Проверка API изнутри кластера (ожидается "https://login.sarex.local.uralkali.com"):

kubectl -n zitadel exec deploy/zitadel-idp-contour -c istio-proxy -- \
  curl -s -H 'x-zitadel-instance-host: login.sarex.local.uralkali.com' -H 'X-Forwarded-Proto: https' \
  http://zitadel-idp-contour.zitadel.svc.cluster.local:8080/.well-known/openid-configuration

Проверка после подключения

  • kubectl -n security-audit logs job/idp-actions-bootstrap -c bootstrap: Target …: создан, по событиям создано/без изменений, ошибок=0. Строки known: — сверка имён с инстансом.
  • Создать тестового пользователя → в security.idp-events есть user.human.added без event_payload и с details.user_name.
  • Неверный пароль → user.human.password.check.failed, sarex_outcome=denied_authn.
  • Канарейка в пароле тестового пользователя → её нет в топике (grep по выводу kafka-console-consumer.sh).
  • curl -X POST http://access-collector.security-audit:8088/zitadel/events -d '{}' из пода в другом namespace → таймаут (NetworkPolicy). Из пода с sidecar в zitadel, но не от SA zitadel → 403 RBAC: access denied.

Риски

  • Повторов доставки нет. Для restAsync Zitadel 4.12.2 только пишет ошибку в лог (internal/execution): коллектор или Kafka недоступны → событие потеряно. Что смягчает:

    • 2 реплики, PDB, очередь отправки;
    • 200 приходит после приёма в batch, дальше доставка в Kafka с ретраями.

    Алерты:

    • otelcol_exporter_send_failed_log_records{exporter="kafka/idp"};
    • нет otelcol_receiver_accepted_log_records{receiver="webhookevent/zitadel"} в рабочее время.
  • Полный источник — Events API Zitadel (POST /admin/v1/events/_search). Суточную сверку количества по типам можно добавить позже.

  • Размер тела. webhookevent 0.121 читает тело одним bufio.Scanner без явного буфера: событие больше ~64 КиБ, вероятно, отбрасывается. Ответ при этом 200. TODO(verify) на стенде; события пользователей обычно в разы меньше.

  • В 0.121 у webhookevent нет header_attribute_regex, есть только required_header: заголовки в запись не попадают.

Откат

Снять Target со всех Execution'ов из списка и удалить его — скрипт с --remove:

kubectl -n security-audit get job idp-actions-bootstrap -o json \
  | jq 'del(.metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp, .metadata.labels,
            .metadata.annotations, .metadata.ownerReferences, .spec.selector, .status)
        | .spec.template.metadata.labels |= with_entries(select(.key | test("controller-uid|job-name") | not))
        | .metadata.name = "idp-actions-remove" | .spec.template.spec.containers[0].args = ["--remove"]' \
  | kubectl create -f -

Пока модуль подключён, Flux вернёт Execution'ы при следующем пересоздании Job'а. Для постоянного отключения убрать idp-actions-bootstrap из base/kustomization.yaml или модуль из кластера.

Журнал аудита Vault

П.19 документа ИБ: аудит Vault включён, чувствительные значения хешируются HMAC-SHA256, журнал идёт в поток ИБ. П.23: секретов в журнале нет.

Vault ──audit device "file"──► /vault/audit/audit.log   (VCT "audit", свой PVC у каждой реплики)
  │  sidecar audit-rotate (busybox): ротация по размеру + SIGHUP vault (shareProcessNamespace)
  │  sidecar audit-shipper (otelcol-contrib): filelog, чекпоинты и очередь — /vault/audit/.otelcol
  ▼  OTLP gRPC, mTLS (Vault и коллектор в mesh)
access-collector: otlp/vault :4319 → logs/vault → Kafka security.vault-audit

Vault-часть — оверлей infrastructure/vault/uralkal-audit/ (наследует vault/uralkal), не подключён.

Почему файл и sidecar

Vault работает fail-closed: если включённый audit device не может записать событие, Vault перестаёт обслуживать запросы. Поэтому в пути записи нет сети, только локальный файл. Kafka, коллектор и сеть могут лежать сколько угодно: Vault пишет в файл, shipper догоняет потом. Остаётся один риск того же класса — полный диск: ротация и очередь ограничены, на заполнение PVC нужен алерт.

Что хешируется: file device по умолчанию заменяет все строковые значения запросов и ответов (секреты, токены, client_token, accessor'ы) на hmac-sha256:… с ключом, известным только Vault. Одинаковые значения дают одинаковый хеш: по нему можно сверять события (vault write sys/audit-hash/file input=…), не раскрывая значения. log_raw не включается никогда: с ним в журнал попадают секреты открытым текстом, а журнал уходит в Kafka и S3.

Кредов Kafka у sidecar'а нет и быть не может: Vault Agent в поде самого Vault невозможен, Vault стартует запечатанным. Креды есть только у access-collector.

Решения с последствиями

  • Том: VCT, а не отдельный PVC. В Уралкалии 3 реплики HA/raft (дефолт чарта, ha.enabled: true, replicas: 3; vault/uralkal это не меняет). Каждой реплике нужен свой том. Это server.auditStorage чарта → volumeClaimTemplates: audit (5Gi local-path, TODO(verify) объём). volumeClaimTemplates — неизменяемое поле StatefulSet: обычный helm upgrade упадёт, нужна процедура с kubectl delete sts --cascade=orphan (ниже).
  • Unseal вручную. vault-unseal в Уралкалии не подключён (есть только в vk-infra, yc-infra-prod). Ключи — в Secret vault/vault-secret (docs/closed-contour-deployment.md). Новый pod template перезапустит все 3 пода, каждый стартует sealed. Readiness чарта — vault status: sealed-под не Ready, и RollingUpdate ждёт его unseal, прежде чем перейти к следующему. Поды распечатываются по одному, кворум raft (2 из 3) сохраняется.
  • mTLS без PERMISSIVE. У подов Vault есть istio-proxy: ns vault с istio-injection: enabled, у чарта нет opt-out. PeerAuthentication в контуре нет, т. е. PERMISSIVE по умолчанию. shipper шлёт plaintext gRPC (tls.insecure: true) до своего istio-proxy, дальше auto-mTLS. На коллекторе AuthorizationPolicy: 4319 только от cluster.local/ns/vault/sa/vault-vault-contour, плюс NetworkPolicy только из ns vault, app.kubernetes.io/name=vault-contour, component=server. TODO(verify): kubectl -n vault get pod vault-vault-contour-0 -o jsonpath='{.spec.containers[*].name}' содержит istio-proxy. Если Vault окажется вне mesh, principal не будет, коллектор ответит 403, shipper накопит очередь, а потом перестанет читать файл. Vault продолжит работать, но после KEEP ротаций начнутся потери. Тогда — вариант (б): TLS на otlp/vault (cert-manager), CA в sidecar. Вариант (а) (PERMISSIVE на 4319) ничего не даёт, пока нет STRICT, а principal без mTLS всё равно пуст.
  • Общий PID namespace и UID vault у sidecar'ов. SIGHUP можно отправить только процессу своего UID: CAP_KILL у non-root контейнера не работает, ambient capabilities нет. Файл 0600 читается только UID 100. Последствие: sidecar'ы видят процесс vault и могут читать /proc/<pid>/environ, а при разрешающем kernel.yama.ptrace_scope и dumpable-процессе — и его память. Смягчение: образы только из registry контура (busybox закреплён digest'ом), readOnlyRootFilesystem, drop: [ALL], no-new-privileges, без shell-доступа в под. TODO(verify): значение ptrace_scope на узлах dedicated: generic.
  • Отклонения от исходного шаблона:
    • rotate.sh проверяет pid vault до mv. Без SIGHUP Vault продолжил бы писать в audit.log.1, новый файл не появился бы, ротация встала бы, а файл рос бы до заполнения диска;
    • queue_size shipper'а — 1000, а не 20000: 20000 батчей по 500 записей не помещаются в PVC. При полной очереди filelog перестаёт читать, и буфером служат сами файлы (KEEP × MAX_BYTES);
    • compaction у file_storage: иначе файл bbolt-очереди не уменьшается.
  • ConfigMap'ы без хеша в имени (audit-shipper-config, audit-rotate-script): правка не перезапускает Vault сама (рестарт = unseal). Применить — ручной рестарт подов по одному с unseal.

Включение audit device

Через terraform контура (infra/terraform, ветка contour, рядом с vault-platform-secrets) — в этой задаче не сделано:

resource "vault_audit" "file" {
  type    = "file"
  path    = "file"
  options = {
    file_path            = "/vault/audit/audit.log"
    mode                 = "0600"
    format               = "json"
    elide_list_responses = "true"   # Vault 1.21.2 (образ vault:1.21.2) — поддерживается
  }
}

Альтернатива — CLI на активном узле: vault audit enable file file_path=/vault/audit/audit.log mode=0600 format=json elide_list_responses=true.

Audit device общий для кластера raft: все реплики должны иметь /vault/audit до включения. Иначе узел без тома, став активным, не сможет писать журнал и перестанет обслуживать запросы.

Порядок подключения (строго)

  1. Топик security.vault-audit: 3 партиции, retention.ms=259200000, RF по контуру. Пользователь security-audit, ACL не нужны (как у остальных топиков модуля).
  2. security-audit подключён и работает: access-collector с logs/vault, порт 4319 в Service.
  3. Подготовка Vault:
    • vault operator raft list-peers: 3 voter'а, лидер есть;
    • vault operator raft snapshot save /tmp/pre-audit.snap, снапшот скопировать из пода;
    • ключи unseal из vault/vault-secret под рукой.
  4. Переключение оверлея. VCT неизменяем, поэтому HelmRelease приостанавливается до пересоздания StatefulSet:
    flux suspend helmrelease vault -n vault
    
    MR: в clusters/uralkal/kustomization.yaml ../../infrastructure/vault/uralkal → ../../infrastructure/vault/uralkal-audit, дождаться применения Kustomization flux-system: ConfigMap'ы появились, HelmRelease обновлён, но приостановлен.
    kubectl -n vault get cm audit-shipper-config audit-rotate-script
    kubectl -n vault delete sts vault-vault-contour --cascade=orphan   # поды и PVC data остаются, Vault работает
    kubectl -n vault get pods -l component=server                       # все 3 Running, unsealed
    flux resume helmrelease vault -n vault                              # helm создаёт STS с новым template и VCT audit
    
  5. RollingUpdate пересоздаёт поды с конца (-2, -1, -0). Каждый новый под стартует sealed: kubectl -n vault exec vault-vault-contour-N -c vault -- vault operator unseal (порог ключей), дождаться Ready, и только потом следующий под.
  6. После раскатки:
    • 3 пода Ready, в каждом контейнеры vault, audit-shipper, audit-rotate, istio-proxy;
    • PVC audit-vault-vault-contour-{0,1,2} в состоянии Bound;
    • vault operator raft list-peers — 3 voter'а;
    • на каждом поде kubectl -n vault exec vault-vault-contour-N -c vault -- sh -c 'touch /vault/audit/.w && rm /vault/audit/.w'.
  7. Только теперь — vault_audit (terraform) или vault audit enable. Если включить раньше, enable упадёт: Vault проверяет запись при включении.
  8. Проверка (ниже).

⚠ Откат

Сначала vault audit disable file (или убрать vault_audit из terraform и применить), убедиться, что vault audit list пуст. Потом вернуть vault/uralkal. Удаление VCT — тоже изменение неизменяемого поля, поэтому та же процедура: suspend → delete sts --cascade=orphan → resume → unseal по одному. PVC audit-* после отката остаются, удалить вручную, когда журнал уже не нужен.

Если вернуть оверлей с включённым audit device, у Vault пропадёт /vault/audit, он не сможет писать журнал и перестанет обслуживать запросы.

Проверка после подключения

  • vault audit list: device file/.
  • Чтение тестового секрета → событие в security.vault-audit с sarex_kind=vault_audit, значение — hmac-sha256:…, записи "type": "request" нет.
  • Запрос без прав → sarex_outcome=denied_authz; неудачный логин → denied_authn.
  • kubectl -n security-audit scale deploy/access-collector --replicas=0 на 5 минут → Vault отвечает. После возврата 2 реплик события дочитаны без пропусков (сверка request.id).
  • Искусственная ротация: MAX_BYTES поменять в ConfigMap нельзя, это env в values. Временно на одном поде: kubectl -n vault exec vault-vault-contour-N -c audit-rotate -- sh -c 'MAX_BYTES=1 INTERVAL=1 timeout 5 sh /scripts/rotate.sh' → появился audit.log.1, новый audit.log создан Vault, события не потеряны.

Мониторинг и алерты

  • Заполнение PVC audit-* > 80% — критично: при полном диске Vault встаёт. Метрика kubelet_volume_stats_*.

  • Ошибки и отставание audit-shipper:

    • otelcol_exporter_send_failed_log_records{exporter="otlp/audit"};
    • otelcol_exporter_queue_size против otelcol_exporter_queue_capacity.

    Метрики на :8888 пода Vault; сбора метрик в Уралкалии пока нет.

  • Отключение audit device: событие с путём sys/audit/* в самом журнале → правило алерта в processor.

  • Строки rotate: ОШИБКА в логе audit-rotate.

Открытые вопросы

  • Размер PVC, MAX_BYTES, KEEP, queue_size — по замеру объёма. KEEP × MAX_BYTES должен перекрывать худший простой коллектора, а их сумма с очередью — оставаться ниже 80% PVC.
  • Фильтрация шумных путей средствами Vault: в OSS есть только elide_list_responses и hmac_accessor; фильтры audit (filter) — Enterprise. Поэтому request-записи отбрасываются в shipper'е.
  • Какой узел что пишет: standby в OSS пересылает запросы на активный, поэтому основной поток — с лидера; shipper есть на всех трёх.

Проверить перед подключением

Сверено по репозиторию и чартам 01.11.2026, доступа к кластеру не было.

Что Результат Как проверено
Образ и command.name .../opentelemetry-collector/opentelemetry-collector-contrib:0.121.0-amd64, otelcol-contrib дефолт values.yaml чарта opentelemetry-collector-prod 0.117.1 (appVersion 0.120.0, тег образа 0.121.0); otelcol-contrib components в образе: filelog, transform, kafka, file_storage, health_check, memory_limiter, batch есть
Синтаксис kafka exporter для 0.121.0 корректен (topic/encoding наверху, auth.tls) otelcol-contrib validate в том же образе — OK
Kafka Service и порт kafka-kafka-contour.kafka.svc.cluster.local:9092, SASL_SSL (не kafka.kafka...) helm template чарта kafka-contour 0.1.15 с values uralkal; тот же адрес у apps/*/uralkal
CA Kafka Secret kafka/kafka-kafka-contour-tls, ключ ca.crt (helm genCA, 365 дней) шаблон tls-secret.yaml чарта; apps/issues/uralkal монтирует его же
Kafka 4.0.0 и protocol_version: 2.8.0 совместимо (Kafka 4 требует ≥ 2.1) appVersion чарта kafka-contour
Vault HelmRelease vault/vault; injector ходит на http://vault-vault-contour.vault.svc:8200 infrastructure/vault/uralkal, helm template vault-contour 0.2.3
Узлы мастеров метка и taint node-role.kubernetes.io/control-plane так же сажается istio-gateway/uralkal
Рендер чарта нет hostPort, Service, ClusterRole; nodeSelector/tolerations/securityContext как заданы helm template 0.117.1 с values из собранного HelmRelease
Логика transform 5 тестовых событий классифицированы верно, token=***, managedFields вырезаны запуск коллектора локально в том же образе с file-экспортёром вместо kafka
Журнал доступа шлюза (02.11.2026)
Шлюз HelmRelease istio-system/ingressgateway, метка istio: ingressgateway, 1 реплика на control-plane, NodePort 30080/30443, externalTrafficPolicy не задан (Cluster) helm template istio-gateway-contour 1.24.0 с values uralkal
istiod istiod-contour 1.24.0, ревизий нет; кроме extensionProviders mesh config не изменился helm template до и после правки, diff только в ConfigMap istio
Инъекция sidecar ns istio-injection=enabled или ns без метки + метка пода sidecar.istio.io/inject=true; disabled блокирует оба селекторы MutatingWebhookConfiguration из рендера istiod-contour
mTLS PeerAuthentication в репозитории нет → PERMISSIVE поиск по infrastructure/, clusters/uralkal, чарту istio-config-contour
Vault + sidecar excludeOutboundPorts: "8200" рядом с agent-init-first apps/bi, apps/bim, apps/cde/base
Образ access-collector тот же; otlp receiver, transform, kafka exporter есть otelcol-contrib validate — OK
Логика transform/access 7 записей в формате Envoy ALS: - → пусто, token/***, ***jwt***, классы верные локальный коллектор, OTLP/HTTP → file-экспортёр
Схемы Istio EnvoyFilter (v1alpha3) и Telemetry (v1) проходят CRD-схемы istio-base-contour 1.24.0 свой проверщик по openAPIV3Schema; istioctl/kubeconform на машине нет. Конфиг Lua (patch.value) схемой не проверяется
События Zitadel (02.11.2026)
Zitadel в mesh ns zitadel istio-injection: enabled, у Deployment sidecar.istio.io/inject: "true", SA zitadel, метки app.kubernetes.io/name=idp-contour, component=start; trust domain cluster.local helm template idp-contour 4.12.13 с values uralkal, mesh config istiod
Версия Zitadel образ contour/zitadel/zitadel:4.12.2 values.yaml чарта
Actions v2 API POST /v2/actions/targets, POST /v2/actions/targets/{id} (update), POST …/targets/search (targetNameFilter), PUT /v2/actions/executions (targets — список id), POST …/executions/search (inConditionsFilter); условие event.event/event.group/event.all; feature flag не нужен proto zitadel/action/v2, feature/v2 тега v4.12.2
Формат события ContextInfoEvent, event_payload — json.RawMessage; повторов для async нет internal/repository/execution/queue.go, internal/execution/*.go v4.12.2
Имена событий все 58 из events.txt есть в internal/repository/{user,usergrant,org,project,instance,member} v4.12.2; агрегата group нет исходники; окончательно — строки known: в логе Job'а
webhookevent 0.121 есть в образе; 200 после ConsumeLogs (с batch — после приёма в батч), 500 при ошибке; одна запись на запрос; required_header есть, header_attribute_regex нет исходники receiver v0.121.0; локальный прогон
OTTL ParseJSON, Decode(…, "base64"), IsString, IsMatch, IsMap, литерал map, set во вложенный отсутствующий map — работают validate и прогон 6 событий: канарейки (хеш, base64, не-JSON) не дошли
Образ Job curl + jq вместе нет; kubectl:1.33.4 (jq 1.6) + kafka:4.0.0-debian-12-r10 (curl 7.88) docker run образов из registry контура
Журнал аудита Vault (02.11.2026)
Чарт vault-contour 0.2.3 values: server.auditStorage (VCT audit + mount в vault), extraContainers, volumes, volumeMounts, shareProcessNamespace, extraEnvironmentVars — postRenderers не нужны шаблоны server-statefulset.yaml, _helpers.tpl
Режим Vault HA raft, 3 реплики, VCT data, RollingUpdate, podManagementPolicy: Parallel; UID/GID 100/1000, fsGroup: 1000; SA vault-vault-contour; метки app.kubernetes.io/name=vault-contour, component=server helm template с values vault/uralkal
Unseal ручной: vault-unseal в Уралкалии не подключён; readiness vault status держит rollout до unseal clusters/*, docs/closed-contour-deployment.md, values.yaml чарта
Istio у Vault ns vault istio-injection: enabled, opt-out в чарте нет → в mesh; PeerAuthentication нет рендер, infrastructure/vault/base/namespace.yaml
Версия Vault 1.21.2: elide_list_responses есть, file device по SIGHUP переоткрывает файл тег образа
Образы opentelemetry-collector-contrib:0.121.0-amd64; busybox 1.37.0 contour/local-path-provisioner-nn/busybox@sha256:11f8… (sh, stat, pidof, kill, mv) docker run из registry
vault/uralkal-audit собирается; postRenderers (nodeSelector/tolerations) на месте; ConfigMap'ы в vault; vault/uralkal побайтно тот же kubectl kustomize, diff
Итоговый StatefulSet shareProcessNamespace: true, VCT data + audit, оба sidecar'а UID 100/GID 1000, /vault/audit rw у vault и sidecar'ов helm template + post-render тем же kustomize-патчем, что у Flux
Ротация и доставка фиктивный vault (по SIGHUP переоткрывает файл) + rotate.sh (MAX_BYTES=20000, 16 ротаций) + shipper + access-collector: 1500/1500 событий, 0 дублей, request отброшены, denied_authz размечен; то же при остановке коллектора на 25 с Docker: общий PID namespace, том с правами 100:1000/0600, --read-only, --cap-drop ALL
rotate.sh sh -n busybox/dash/bash --posix — OK; без процесса vault ротация пропускается (файл не переименован); SIGTERM — выход за 0 с busybox из registry. shellcheck на машине нет
Скрипт sh -n dash/bash --posix/busybox — OK; mock: создание, повтор без изменений, объединение с чужим target, обновление endpoint, --remove, API недоступен, 401; утечек PAT/signingKey нет mock Zitadel на Python, запуск в образе kafka с --read-only, uid 1001. shellcheck на машине нет

Остаётся открытым:

  • TODO(verify) nodelocaldns (169.254.25.10) в NetworkPolicy: в репозитории не подтверждается. При дефолте kubespray включён, если нет — правило безвредно.
  • RequestAuthentication не создана: не подтверждены издатели django и document-link, issuer Zitadel (TODO(verify)) — см. «Пользователь в журнале». Пока user_sub пустой.
  • IP клиента за NodePort — см. «Открытые вопросы» журнала доступа.
  • set(body, attributes) + encoding: raw даёт JSON-объект (проверено локально через file-экспортёр, raw-маршалер сериализует map в JSON) — подтвердить на стенде чтением топика. Там же проверить, что Envoy шлёт labels строками (сравнения response_code == "401").
  • TODO(verify) API Zitadel изнутри кластера с x-zitadel-instance-host (команда в «События Zitadel»): по исходникам должно работать, запрос не выполнялся.
  • TODO(verify) allowlist полей details по реальным событиям; лимит ~64 КиБ тела у webhookevent.
  • TODO(verify) toolbox-образ для Job вместо пары kubectl + kafka.
  • TODO(verify) Vault: istio-proxy у подов Vault; ptrace_scope на узлах; объём PVC audit, MAX_BYTES/KEEP/queue_size по замеру; создание STS заново через helm upgrade после --cascade=orphan (Helm создаёт отсутствующий ресурс — проверить на стенде до Уралкалия).
  • Некритичные предупреждения 0.121.0: service::telemetry::metrics::address устарел (так же во всех коллекторах репо); OTTL-пути без префикса log. (контекст выводится автоматически).