#!/bin/sh # Идемпотентная настройка Zitadel Actions v2 для журнала ИБ: Target (restAsync) → access-collector # и Execution'ы на события из EVENTS_FILE. С флагом --remove (или MODE=remove) снимает Target # с этих Execution'ов и удаляет его. # # Без set -x: заголовки с PAT лежат в файле $TMP/headers и в вывод не попадают. # Ответы API с signingKey наружу не выводятся: коллектор подпись не проверяет. При переходе на приёмник # с проверкой HMAC ключ из ответа CreateTarget нужно будет записать во Vault, а не в лог. # # Код выхода ≠ 0 при любой ошибке; остальные события при этом всё равно обрабатываются. set -eu MODE="${MODE:-apply}" [ "${1:-}" = "--remove" ] && MODE=remove : "${ZITADEL_API:?ZITADEL_API не задан (http://:8080)}" : "${ZITADEL_HOST:?ZITADEL_HOST не задан (внешний домен инстанса, ExternalDomain)}" TARGET_NAME="${TARGET_NAME:-security-audit-idp-events}" TARGET_ENDPOINT="${TARGET_ENDPOINT:-http://access-collector.security-audit.svc.cluster.local:8088/zitadel/events}" TARGET_TIMEOUT="${TARGET_TIMEOUT:-10s}" EVENTS_FILE="${EVENTS_FILE:-/config/events.txt}" PAT_FILE="${PAT_FILE:-/vault/secrets/zitadel-pat}" CURL_MAX_TIME="${CURL_MAX_TIME:-20}" CONNECT_ATTEMPTS="${CONNECT_ATTEMPTS:-10}" CONNECT_PAUSE="${CONNECT_PAUSE:-15}" ISTIO_QUIT_ON_EXIT="${ISTIO_QUIT_ON_EXIT:-false}" TMP="${TMPDIR:-/tmp}" # Job в mesh: istio-proxy сам не завершится, без quitquitquit под останется Running. quit_proxy() { if [ "$ISTIO_QUIT_ON_EXIT" = "true" ]; then curl -fsS --max-time 5 -X POST http://127.0.0.1:15020/quitquitquit >/dev/null 2>&1 || true fi } trap quit_proxy EXIT log() { printf '%s\n' "$*"; } err() { printf 'ОШИБКА: %s\n' "$*" >&2; } [ -r "$PAT_FILE" ] || { err "нет файла PAT $PAT_FILE (Vault Agent)"; exit 1; } [ -r "$EVENTS_FILE" ] || { err "нет списка событий $EVENTS_FILE"; exit 1; } # Инстанс выбирается по x-zitadel-instance-host (InstanceHostHeaders Zitadel), Host остаётся именем Service: # по нему istio-proxy находит маршрут и шлёт запрос с mTLS. X-Forwarded-Proto — инстанс с ExternalSecure. umask 077 { printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' <"$PAT_FILE")" printf 'x-zitadel-instance-host: %s\n' "$ZITADEL_HOST" printf 'x-zitadel-public-host: %s\n' "$ZITADEL_HOST" printf 'X-Forwarded-Proto: https\n' printf 'Content-Type: application/json\n' printf 'Accept: application/json\n' } >"$TMP/headers" # api METHOD PATH [BODY]: тело ответа в stdout при 2xx; иначе код и текст ошибки в stderr, return 1. api() { _method=$1 _path=$2 if [ $# -ge 3 ]; then set -- --data "$3"; else set --; fi _code=$(curl -sS -o "$TMP/resp" -w '%{http_code}' --max-time "$CURL_MAX_TIME" \ -X "$_method" -H @"$TMP/headers" "$@" "$ZITADEL_API$_path") || _code=000 case "$_code" in 2??) cat "$TMP/resp" ;; *) err "$_method $_path → HTTP $_code: $(head -c 300 "$TMP/resp" 2>/dev/null | tr '\n' ' ')"; return 1 ;; esac } # --- 1. Доступность API (Zitadel и коллектор поднимаются раньше Job'а, но без dependsOn) --- attempt=1 until curl -fsS -o /dev/null --max-time "$CURL_MAX_TIME" -H @"$TMP/headers" "$ZITADEL_API/debug/ready"; do if [ "$attempt" -ge "$CONNECT_ATTEMPTS" ]; then err "Zitadel недоступен: $ZITADEL_API (попыток: $attempt)"; exit 1 fi log "Zitadel не готов, попытка $attempt/$CONNECT_ATTEMPTS, пауза ${CONNECT_PAUSE}s" attempt=$((attempt + 1)) sleep "$CONNECT_PAUSE" done # --- 2. Типы событий инстанса: в лог — для сверки списка, и для проверки имён ниже --- # Ответ сначала в переменную: в конвейере api | jq код ошибки api потерялся бы (set -e смотрит на jq). resp=$(api POST /admin/v1/events/types/_search '{}') printf '%s' "$resp" | jq -r '.eventTypes[]?.type' | sort -u >"$TMP/known-events" log "Типы событий Zitadel ($(wc -l <"$TMP/known-events")):" sed 's/^/ known: /' "$TMP/known-events" sed -e 's/#.*//' -e 's/[[:space:]]//g' "$EVENTS_FILE" | grep -v '^$' | sort -u >"$TMP/events" # --- 3. Target --- search=$(jq -nc --arg n "$TARGET_NAME" \ '{filters: [{targetNameFilter: {targetName: $n, method: "TEXT_FILTER_METHOD_EQUALS"}}]}') resp=$(api POST /v2/actions/targets/search "$search") printf '%s' "$resp" | jq -c --arg n "$TARGET_NAME" '[.targets[]? | select(.name == $n) | {id, endpoint, timeout, async: has("restAsync")}]' >"$TMP/targets" found=$(jq length "$TMP/targets") if [ "$found" -gt 1 ]; then err "Target'ов с именем $TARGET_NAME: $found, ожидался один — разберитесь вручную"; exit 1 fi target_id=$(jq -r '.[0].id // empty' "$TMP/targets") if [ "$MODE" = "apply" ]; then want=$(jq -nc --arg e "$TARGET_ENDPOINT" --arg t "$TARGET_TIMEOUT" \ '{restAsync: {}, endpoint: $e, timeout: $t}') if [ -z "$target_id" ]; then # Из ответа берётся только id: signingKey не выводится и не сохраняется. resp=$(api POST /v2/actions/targets "$(printf '%s' "$want" | jq -c --arg n "$TARGET_NAME" '. + {name: $n}')") target_id=$(printf '%s' "$resp" | jq -r '.id // empty') [ -n "$target_id" ] || { err "CreateTarget не вернул id"; exit 1; } log "Target $TARGET_NAME: создан, id=$target_id" elif jq -e --arg e "$TARGET_ENDPOINT" --arg t "$TARGET_TIMEOUT" \ '.[0] | .async and .endpoint == $e and .timeout == $t' "$TMP/targets" >/dev/null; then log "Target $TARGET_NAME: без изменений, id=$target_id" else api POST "/v2/actions/targets/$target_id" "$want" >/dev/null log "Target $TARGET_NAME: обновлён (тип/endpoint/timeout), id=$target_id" fi elif [ -z "$target_id" ]; then log "Target $TARGET_NAME не найден — снимать нечего"; exit 0 fi # --- 4. Execution'ы: чужие targets сохраняются, свой добавляется в конец (или убирается при --remove) --- rc=0; n_created=0; n_extended=0; n_same=0; n_removed=0; n_err=0 while IFS= read -r ev; do if ! grep -qxF "$ev" "$TMP/known-events"; then log " $ev: ошибка — такого типа нет в Zitadel"; n_err=$((n_err + 1)); rc=1; continue fi query=$(jq -nc --arg e "$ev" '{filters: [{inConditionsFilter: {conditions: [{event: {event: $e}}]}}]}') if ! resp=$(api POST /v2/actions/executions/search "$query"); then log " $ev: ошибка чтения"; n_err=$((n_err + 1)); rc=1; continue fi current=$(printf '%s' "$resp" \ | jq -c --arg e "$ev" '[.executions[]? | select(.condition.event.event == $e) | .targets[]?]') has=$(printf '%s' "$current" | jq --arg t "$target_id" 'index($t) != null') if [ "$MODE" = "apply" ]; then if [ "$has" = "true" ]; then log " $ev: без изменений"; n_same=$((n_same + 1)); continue; fi targets=$(printf '%s' "$current" | jq -c --arg t "$target_id" '. + [$t]') if [ "$current" = "[]" ]; then status="создано"; else status="дополнено (были другие targets)"; fi else if [ "$has" != "true" ]; then log " $ev: без изменений"; n_same=$((n_same + 1)); continue; fi targets=$(printf '%s' "$current" | jq -c --arg t "$target_id" 'map(select(. != $t))') status="target снят" fi body=$(jq -nc --arg e "$ev" --argjson t "$targets" '{condition: {event: {event: $e}}, targets: $t}') if api PUT /v2/actions/executions "$body" >/dev/null; then log " $ev: $status" case "$status" in создано) n_created=$((n_created + 1)) ;; дополнено*) n_extended=$((n_extended + 1)) ;; *) n_removed=$((n_removed + 1)) ;; esac else log " $ev: ошибка записи"; n_err=$((n_err + 1)); rc=1 fi done <"$TMP/events" if [ "$MODE" = "remove" ] && [ "$rc" -eq 0 ]; then api DELETE "/v2/actions/targets/$target_id" >/dev/null && log "Target $TARGET_NAME удалён, id=$target_id" || rc=1 fi log "Итог ($MODE): target=$target_id, событий=$(wc -l <"$TMP/events"), создано=$n_created, дополнено=$n_extended, снято=$n_removed, без изменений=$n_same, ошибок=$n_err" exit "$rc"