iac/infrastructure/security-audit/base/idp-actions-bootstrap/files/idp-actions-bootstrap.sh

160 lines
8.6 KiB
Bash
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.

#!/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://<service>: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"