#!/usr/bin/env bash set -Eeuo pipefail set +x umask 077 readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)" readonly KC_NS=keycloak readonly KC_NAME=keycloak readonly KC_SERVICE=keycloak-service readonly KC_HOST=id.learn.hyeonworks.com readonly REALM=hyeonworks readonly PORT=18081 readonly BASE=http://127.0.0.1:$PORT execute=false object_admin="" db_admin="" temp="" pf="" rollback_armed=false ai_client_created=false pg_client_created=false ai_group_created=false pg_group_created=false ai_member_added=false pg_member_added=false ai_client_uuid="" pg_client_uuid="" ai_group_uuid="" pg_group_uuid="" ai_user_uuid="" pg_user_uuid="" fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' 사용법: bash scripts/bootstrap/configure-keycloak-admin-oidc.sh bash scripts/bootstrap/configure-keycloak-admin-oidc.sh --execute \ --object-admin donghyeon.kang --db-admin donghyeon.kang 두 confidential client, 두 관리자 그룹, claim mapper와 다음 Secret을 만듭니다. - object-storage/aistor-keycloak-oidc: client-id, client-secret - platform-admin/pgadmin-keycloak-oidc: client-id, client-secret USAGE } while (( $# > 0 )); do case "$1" in --execute) execute=true; shift ;; --object-admin) (( $# >= 2 )) || fail "--object-admin 값이 필요합니다" object_admin="$2"; shift 2 ;; --db-admin) (( $# >= 2 )) || fail "--db-admin 값이 필요합니다" db_admin="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; *) usage >&2; fail "지원하지 않는 인자: $1" ;; esac done [[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요" for cmd in base64 chmod curl jq kill kubectl mktemp rg rm seq sleep sort tr; do command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다" done context="$(kubectl config current-context)" api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" kubectl -n "$KC_NS" wait --for=condition=Ready "keycloak.k8s.keycloak.org/$KC_NAME" --timeout=30s >/dev/null kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" >/dev/null printf 'Current context: %s\nRealm: %s\n' "$context" "$REALM" printf 'Clients: aistor-console, pgadmin\n' printf 'Groups: /platform-object-admins, /platform-db-admins\n' [[ -n "$object_admin" ]] && printf 'Object admin: %s\n' "$object_admin" [[ -n "$db_admin" ]] && printf 'DB admin: %s\n' "$db_admin" if [[ "$execute" == false ]]; then printf 'DRY RUN PASS: --execute를 지정하지 않아 변경하지 않았습니다.\n' exit 0 fi [[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다" printf 'Type APPLY %s to configure admin OIDC: ' "$context" read -r answer [[ "$answer" == "APPLY $context" ]] || fail "취소했습니다" [[ "$(kubectl config current-context)" == "$context" ]] || fail "context가 바뀌었습니다" [[ "$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" == "$api" ]] || fail "API server가 바뀌었습니다" kubectl apply -k "$ROOT/infrastructure/namespaces/admin-tools" >/dev/null temp="$(mktemp -d /tmp/keycloak-admin-oidc.XXXXXX)" admin_user="$temp/admin-user" admin_password="$temp/admin-password" token_json="$temp/token.json" token="$temp/token" auth="$temp/auth.conf" response="$temp/response.json" early_cleanup() { rc=$? trap - EXIT INT TERM if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then kill "$pf" 2>/dev/null || true wait "$pf" 2>/dev/null || true fi case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac exit "$rc" } trap early_cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \ -o jsonpath='{.data.username}' | base64 -d >"$admin_user" kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \ -o jsonpath='{.data.password}' | base64 -d >"$admin_password" chmod 0600 "$admin_user" "$admin_password" kubectl -n "$KC_NS" port-forward --address=127.0.0.1 "service/$KC_SERVICE" \ "$PORT:8080" >"$temp/port-forward.log" 2>&1 & pf=$! ready=false for _ in $(seq 1 30); do kill -0 "$pf" 2>/dev/null || fail "Keycloak port-forward가 종료됐습니다" if rg -q -F "Forwarding from 127.0.0.1:$PORT -> 8080" "$temp/port-forward.log"; then ready=true break fi sleep 1 done [[ "$ready" == true ]] || fail "Keycloak port-forward가 준비되지 않았습니다" kc_curl() { curl --disable --silent --show-error --noproxy '*' \ --connect-timeout 3 --max-time 20 \ --header "Host: $KC_HOST" \ --header 'X-Forwarded-Proto: https' \ --header 'X-Forwarded-Port: 443' "$@" } kc_curl --fail-with-body --output "$token_json" \ --data-urlencode grant_type=password --data-urlencode client_id=admin-cli \ --data-urlencode "username@$admin_user" --data-urlencode "password@$admin_password" \ "$BASE/realms/master/protocol/openid-connect/token" >/dev/null jq -er '.access_token | select(type == "string" and length > 0)' "$token_json" >"$token" { printf 'header = "Authorization: Bearer ' tr -d '\r\n' <"$token" printf '"\n' } >"$auth" chmod 0600 "$token" "$auth" request() { kc_curl --config "$auth" "$@" } backup_secret() { if kubectl -n "$1" get "secret/$2" -o yaml >"$3" 2>/dev/null; then printf true else printf false fi } ai_secret_existed="$(backup_secret object-storage aistor-keycloak-oidc "$temp/ai-secret.yaml")" pg_secret_existed="$(backup_secret platform-admin pgadmin-keycloak-oidc "$temp/pg-secret.yaml")" upsert_group() { group_name="$1" policy="$2" before="$3" body="$temp/group.json" request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \ -o "$response" "$BASE/admin/realms/$REALM/groups" group_id="$(jq -r --arg path "/$group_name" '[.[] | select(.path == $path)][0].id // empty' "$response")" if [[ -n "$policy" ]]; then jq -n --arg name "$group_name" --arg policy "$policy" \ '{name:$name,attributes:{policy:[$policy]}}' >"$body" else jq -n --arg name "$group_name" '{name:$name}' >"$body" fi if [[ -z "$group_id" ]]; then code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups")" [[ "$code" == 201 ]] || fail "그룹 생성 실패: $group_name HTTP $code" request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \ -o "$response" "$BASE/admin/realms/$REALM/groups" group_id="$(jq -er --arg path "/$group_name" '[.[] | select(.path == $path)][0].id' "$response")" last_created=true else request -o "$before" "$BASE/admin/realms/$REALM/groups/$group_id" code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups/$group_id")" [[ "$code" == 204 ]] || fail "그룹 갱신 실패: $group_name HTTP $code" last_created=false fi last_id="$group_id" } upsert_client() { client="$1" display="$2" root_url="$3" redirect="$4" before="$5" body="$temp/client.json" jq -n --arg client "$client" --arg display "$display" --arg root "$root_url" \ --arg redirect "$redirect" '{ clientId:$client,name:$display,enabled:true,protocol:"openid-connect", clientAuthenticatorType:"client-secret",publicClient:false, standardFlowEnabled:true,implicitFlowEnabled:false,directAccessGrantsEnabled:false, serviceAccountsEnabled:false,authorizationServicesEnabled:false, consentRequired:false,fullScopeAllowed:false,rootUrl:$root,baseUrl:($root+"/"), redirectUris:[$redirect],webOrigins:[$root], attributes:{"oauth2.device.authorization.grant.enabled":"false", "oidc.ciba.grant.enabled":"false","post.logout.redirect.uris":($root+"/*")} }' >"$body" request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \ -o "$response" "$BASE/admin/realms/$REALM/clients" count="$(jq 'length' "$response")" [[ "$count" == 0 || "$count" == 1 ]] || fail "$client clientId가 중복됐습니다" if [[ "$count" == 0 ]]; then code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients")" [[ "$code" == 201 ]] || fail "client 생성 실패: $client HTTP $code" last_created=true else client_id="$(jq -er '.[0].id' "$response")" request -o "$before" "$BASE/admin/realms/$REALM/clients/$client_id" code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id")" [[ "$code" == 204 ]] || fail "client 갱신 실패: $client HTTP $code" last_created=false fi request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \ -o "$response" "$BASE/admin/realms/$REALM/clients" last_id="$(jq -er 'select(length == 1) | .[0].id' "$response")" } upsert_mapper() { client_id="$1" mapper_name="$2" mapper_type="$3" body="$temp/mapper.json" if [[ "$mapper_type" == group ]]; then jq -n --arg name "$mapper_name" '{ name:$name,protocol:"openid-connect",protocolMapper:"oidc-group-membership-mapper", consentRequired:false,config:{"claim.name":"groups","full.path":"true", "id.token.claim":"true","access.token.claim":"true", "userinfo.token.claim":"true","introspection.token.claim":"true"} }' >"$body" else jq -n --arg name "$mapper_name" '{ name:$name,protocol:"openid-connect",protocolMapper:"oidc-usermodel-attribute-mapper", consentRequired:false,config:{"user.attribute":"policy","claim.name":"policy", "jsonType.label":"String","multivalued":"true","aggregate.attrs":"true", "id.token.claim":"true","access.token.claim":"true", "userinfo.token.claim":"true","introspection.token.claim":"true"} }' >"$body" fi request -o "$response" "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models" mapper_id="$(jq -r --arg name "$mapper_name" '[.[] | select(.name == $name)][0].id // empty' "$response")" if [[ -z "$mapper_id" ]]; then code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models")" [[ "$code" == 201 ]] || fail "mapper 생성 실패: $mapper_name HTTP $code" else code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \ -o "$response" -w '%{http_code}' \ "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models/$mapper_id")" [[ "$code" == 204 ]] || fail "mapper 갱신 실패: $mapper_name HTTP $code" fi } assign_member() { username="$1" group_id="$2" request --get --data-urlencode "username=$username" --data-urlencode exact=true \ -o "$response" "$BASE/admin/realms/$REALM/users" [[ "$(jq 'length' "$response")" == 1 ]] || fail "사용자를 정확히 찾지 못했습니다: $username" user_id="$(jq -er '.[0].id' "$response")" request -o "$response" "$BASE/admin/realms/$REALM/users/$user_id/groups" if jq -e --arg id "$group_id" 'any(.[]; .id == $id)' "$response" >/dev/null; then last_member_added=false else code="$(request -X PUT -o "$response" -w '%{http_code}' \ "$BASE/admin/realms/$REALM/users/$user_id/groups/$group_id")" [[ "$code" == 204 ]] || fail "그룹 구성원 추가 실패: $username HTTP $code" last_member_added=true fi last_user_id="$user_id" } restore_secret() { if [[ "$1" == true ]]; then kubectl apply -f "$2" >/dev/null 2>&1 || true else kubectl -n "$3" delete "secret/$4" --ignore-not-found >/dev/null 2>&1 fi } rollback() { set +e printf '\nROLLBACK: Keycloak 및 OIDC Secret 이전 상태 복원\n' >&2 [[ "$ai_member_added" == true ]] && \ request -X DELETE "$BASE/admin/realms/$REALM/users/$ai_user_uuid/groups/$ai_group_uuid" >/dev/null [[ "$pg_member_added" == true ]] && \ request -X DELETE "$BASE/admin/realms/$REALM/users/$pg_user_uuid/groups/$pg_group_uuid" >/dev/null for item in "ai $ai_client_uuid $ai_client_created" "pg $pg_client_uuid $pg_client_created"; do set -- $item if [[ "$3" == true ]]; then request -X DELETE "$BASE/admin/realms/$REALM/clients/$2" >/dev/null elif [[ -n "$2" ]]; then request -X PUT -H 'Content-Type: application/json' \ --data-binary "@$temp/$1-client-before.json" \ "$BASE/admin/realms/$REALM/clients/$2" >/dev/null fi done for item in "ai $ai_group_uuid $ai_group_created" "pg $pg_group_uuid $pg_group_created"; do set -- $item if [[ "$3" == true ]]; then request -X DELETE "$BASE/admin/realms/$REALM/groups/$2" >/dev/null elif [[ -n "$2" ]]; then request -X PUT -H 'Content-Type: application/json' \ --data-binary "@$temp/$1-group-before.json" \ "$BASE/admin/realms/$REALM/groups/$2" >/dev/null fi done restore_secret "$ai_secret_existed" "$temp/ai-secret.yaml" object-storage aistor-keycloak-oidc restore_secret "$pg_secret_existed" "$temp/pg-secret.yaml" platform-admin pgadmin-keycloak-oidc rollback_armed=false printf 'ROLLBACK complete.\n' >&2 } cleanup() { rc=$? trap - EXIT INT TERM if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then rollback; fi if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then kill "$pf" 2>/dev/null || true wait "$pf" 2>/dev/null || true fi case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac exit "$rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM rollback_armed=true upsert_group platform-object-admins consoleAdmin "$temp/ai-group-before.json" ai_group_uuid="$last_id"; ai_group_created="$last_created" upsert_group platform-db-admins "" "$temp/pg-group-before.json" pg_group_uuid="$last_id"; pg_group_created="$last_created" upsert_client aistor-console "AIStor Console" https://storage-admin.learn.hyeonworks.com \ https://storage-admin.learn.hyeonworks.com/oauth_callback "$temp/ai-client-before.json" ai_client_uuid="$last_id"; ai_client_created="$last_created" upsert_mapper "$ai_client_uuid" aistor-policy-claim policy upsert_client pgadmin pgAdmin https://db-admin.learn.hyeonworks.com \ https://db-admin.learn.hyeonworks.com/oauth2/authorize "$temp/pg-client-before.json" pg_client_uuid="$last_id"; pg_client_created="$last_created" upsert_mapper "$pg_client_uuid" pgadmin-groups group for item in "object-storage aistor-keycloak-oidc aistor-console $ai_client_uuid ai" \ "platform-admin pgadmin-keycloak-oidc pgadmin $pg_client_uuid pg"; do set -- $item secret_file="$temp/$5-client-secret" request -o "$response" "$BASE/admin/realms/$REALM/clients/$4/client-secret" jq --exit-status --join-output --raw-output '.value | select(type == "string" and length >= 16)' "$response" >"$secret_file" chmod 0600 "$secret_file" kubectl -n "$1" create secret generic "$2" --from-literal="client-id=$3" \ --from-file="client-secret=$secret_file" --dry-run=client -o yaml | kubectl apply -f - >/dev/null kubectl -n "$1" label "secret/$2" app.kubernetes.io/component=oidc-client \ app.kubernetes.io/part-of=platform app.kubernetes.io/managed-by=bootstrap-script \ --overwrite >/dev/null done if [[ -n "$object_admin" ]]; then assign_member "$object_admin" "$ai_group_uuid" ai_member_added="$last_member_added"; ai_user_uuid="$last_user_id" fi if [[ -n "$db_admin" ]]; then assign_member "$db_admin" "$pg_group_uuid" pg_member_added="$last_member_added"; pg_user_uuid="$last_user_id" fi for contract in "object-storage aistor-keycloak-oidc" "platform-admin pgadmin-keycloak-oidc"; do set -- $contract keys="$(kubectl -n "$1" get "secret/$2" \ -o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' | LC_ALL=C sort)" [[ "$keys" == $'client-id\nclient-secret' ]] || fail "$1/$2 Secret 계약이 다릅니다" done request -o "$response" "$BASE/realms/$REALM/.well-known/openid-configuration" jq -e --arg issuer "https://$KC_HOST/realms/$REALM" '.issuer == $issuer' "$response" >/dev/null || \ fail "OIDC discovery issuer가 다릅니다" rollback_armed=false printf 'KEYCLOAK ADMIN OIDC CONFIG SUCCESS\n' printf 'Secret payload, 관리자 암호, token은 출력하지 않았습니다.\n'