iac/infrastructure/security-audit/README.md

691 lines
62 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` |
| журнал аудита 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`):
```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` или модуль из кластера.
## Журнал аудита 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`) — в этой задаче не сделано:
```hcl
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:
```bash
flux suspend helmrelease vault -n vault
```
MR: в `clusters/uralkal/kustomization.yaml` `../../infrastructure/vault/uralkal` → `../../infrastructure/vault/uralkal-audit`,
дождаться применения Kustomization `flux-system`: ConfigMap'ы появились, HelmRelease обновлён, но приостановлен.
```bash
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.` (контекст выводится автоматически).