iac/infrastructure/security-audit
2026-11-02 00:57:05 +03:00
..
base **Add Zitadel events ingestion pipeline with AuthorizationPolicy and initial Job configuration for security-audit namespace** 2026-11-02 00:57:05 +03:00
mesh **Add HelmRelease access-collector for processing ingress-gateway access logs in security-audit namespace with Kafka and Vault integration** 2026-11-02 00:33:20 +03:00
uralkal **Add Zitadel events ingestion pipeline with AuthorizationPolicy and initial Job configuration for security-audit namespace** 2026-11-02 00:57:05 +03:00
kustomization.yaml Add audit collector HelmRelease for security-audit namespace to process and export kube-apiserver audit logs, including Kafka and Vault integration 2026-11-01 22:02:54 +03:00
README.md **Add Zitadel events ingestion pipeline with AuthorizationPolicy and initial Job configuration for security-audit namespace** 2026-11-02 00:57:05 +03:00

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

Одно сообщение — одна запись в 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 —
Оверлей Статус
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 или модуль из кластера.

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

Сверено по репозиторию и чартам 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 контура
Скрипт 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.
  • Некритичные предупреждения 0.121.0: service::telemetry::metrics::address устарел (так же во всех коллекторах репо); OTTL-пути без префикса log. (контекст выводится автоматически).