iac/infrastructure/security-audit/README.md

514 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`):
```bash
kubectl -n kafka exec -it kafka-kafka-contour-controller-0 -c kafka -- bash
```
```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`:
```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 с владельцами приложений):
```yaml
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` даст
предупреждения при создании подов — ожидаемо.
### Проверка после подключения
- Провайдер и порядок фильтров на шлюзе:
```bash
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"`):
```bash
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`:
```bash
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.` (контекст выводится автоматически).