401 lines
16 KiB
Bash
Executable File
401 lines
16 KiB
Bash
Executable File
#!/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'
|