2026-10-11 13:59:47 +07:00
#!/usr/bin/env bash
# =============================================================================
# eBdesk SiCABE — Verifikasi Keycloak (realm, client, user) — LEBIH LENGKAP dari
# verify-phase1. Tujuan: menjamin konfigurasi Keycloak siap dipakai fase berikutnya
# & alur uji E2E (OIDC Trino/StarRocks/Airflow/OM/Grafana; login user engineer/…).
#
# Yang diperiksa:
# A. Realm & roles : discovery/issuer; role admin/engineer/analyst/viewer.
# B. Clients (statis) : per client — ada, enabled, publicClient=false,
# standardFlow, serviceAccounts, client-secret,
# redirectUris/webOrigins (localhost + PUBLIC_URL), audience mapper.
# C. Users (statis) : per user — ada, enabled, realm role benar, dan
# apakah password masih DEFAULT (hardcoded: engineer123/…).
# D. Fungsional : client_credentials tiap service client → token OK (uji SECRET).
# E. Fungsional : login user (password grant) → preferred_username + roles.
# F. Redirect URI : validasi NYATA via /auth — Keycloak MENERIMA redirect alamat
# host (dan MENOLAK yang ngawur). Bukan sekadar banding daftar.
#
# Mode READ-ONLY (tanpa admin Keycloak): bagian statis (B/C) DILEWATI; D/E tetap jalan.
# Exit: 0 = semua gate lolos (WARN boleh), 1 = ada FAIL.
#
# Usage:
# ./scripts/verify-keycloak.sh
# ./scripts/verify-keycloak.sh --env-file generated/host-control/.env
# KEYCLOAK_URL=http://kc.corp:8080 ./scripts/verify-keycloak.sh # IdP existing
# =============================================================================
set -uo pipefail
SCRIPT_DIR = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) /.." && pwd ) "
# shellcheck source=../lib/common.sh
source " ${ SCRIPT_DIR } /lib/common.sh"
# shellcheck source=../config/environment.defaults.sh
source " ${ SCRIPT_DIR } /config/environment.defaults.sh"
sicabe_load_env " ${ SCRIPT_DIR } " " $@ " || exit 1
2026-10-11 18:13:03 +07:00
KC = " $( sicabe_keycloak_url) "
2026-10-11 13:59:47 +07:00
REALM = " ${ SICABE_REALM :- sicabe } "
REQUIRED_CLIENTS =( "trino" "starrocks" "airflow" "openmetadata" "grafana" )
REQUIRED_ROLES =( "admin" "engineer" "analyst" "viewer" )
REQUIRED_USERS =( "admin" "engineer" "analyst" "viewer" )
AUDIENCE_MAPPER = "audience-account"
# Peran yang Diharapkan per user.
declare -A USER_ROLE =([ admin]= admin [ engineer]= engineer [ analyst]= analyst [ viewer]= viewer)
# Password DEFAULT (hardcoded oleh bootstrap.sh) — untuk mendeteksi password lemah.
declare -A USER_DEFAULT_PW =(
[ admin]= " ${ KEYCLOAK_ADMIN_PASSWORD :- } "
[ engineer]= "engineer123"
[ analyst]= "analyst123"
[ viewer]= "viewer123"
)
# Secret client diharapkan (harus sama dgn env deploy) + URL publik (redirect/webOrigins).
declare -A CLIENT_SECRET =(
[ trino]= " ${ TRINO_CLIENT_SECRET :- } " [ starrocks]= " ${ STARROCKS_CLIENT_SECRET :- } "
[ airflow]= " ${ AIRFLOW_CLIENT_SECRET :- } " [ openmetadata]= " ${ OPENMETADATA_CLIENT_SECRET :- } "
[ grafana]= " ${ GRAFANA_CLIENT_SECRET :- } "
)
declare -A CLIENT_PUBLIC =(
[ trino]= " ${ TRINO_PUBLIC_URL :- } " [ starrocks]= " ${ STARROCKS_PUBLIC_URL :- } "
[ airflow]= " ${ AIRFLOW_PUBLIC_URL :- } " [ openmetadata]= " ${ OPENMETADATA_PUBLIC_URL :- } "
[ grafana]= " ${ GRAFANA_PUBLIC_URL :- } "
)
# Origin (skema://alamat:port) tempat BROWSER membuka komponen — WAJIB ada di
# redirectUris. Deteksi kesalahan umum: redirectUris hanya `https://localhost:12443/*`
# padahal user membuka `https://<ip-host>:12443` → Keycloak menolak "invalid redirect_uri".
# <client> = "<scheme> <host_var> <port_var> <default_port>"
2026-10-11 20:10:01 +07:00
# CATATAN: pakai var TOPOLOGI yang DIISI generator. `AIRFLOW_HOST` SENGAJA TIDAK
# diisi (penanda komponen external, lihat generate.py) → memakainya membuat
# comp_addr jatuh ke SICABE_HOST_ADDRESS (alamat host LOKAL), sehingga origin salah
# di host non-pemilik (mis. compute mengharap airflow di alamat compute, bukan control).
2026-10-11 13:59:47 +07:00
declare -A CLIENT_ORIGIN =(
[ trino]= "https TRINO_HOST TRINO_HTTPS_PORT 12443"
[ grafana]= "http GRAFANA_HOST GRAFANA_PORT 12300"
[ openmetadata]= "http OPENMETADATA_HOST OPENMETADATA_PORT 12085"
2026-10-11 20:10:01 +07:00
[ airflow]= "http AIRFLOW_WEBSERVER_HOST AIRFLOW_PORT 12082"
2026-10-11 13:59:47 +07:00
[ starrocks]= "http STARROCKS_FE_HOST STARROCKS_HTTP_PORT 12030"
)
# Alamat efektif: nilai var bila berupa ALAMAT (mengandung titik); kalau kosong/co-located
# → alamat host ini (SICABE_HOST_ADDRESS, multi-host). Kalau KEDUANYA tak ada → KOSONG
# (JANGAN fallback ke localhost: itu membuat cek origin terhadap localhost selalu PASS).
comp_addr() {
local v = " ${ !1 :- } "
if [[ " ${ v } " == *.* && " ${ v } " != "127.0.0.1" ]] ; then echo " ${ v } " ; return ; fi
echo " ${ SICABE_HOST_ADDRESS :- } "
}
expected_origin() { # <client> → "skema://alamat:port" (atau kosong bila alamat tak diketahui)
local spec = " ${ CLIENT_ORIGIN [ $1 ] :- } " ; [[ -n " ${ spec } " ]] || { echo "" ; return ; }
local scheme hv pv dport addr; read -r scheme hv pv dport <<< " ${ spec } "
addr = " $( comp_addr " ${ hv } " ) " ; [[ -n " ${ addr } " ]] || { echo "" ; return ; }
printf '%s://%s:%s' " ${ scheme } " " ${ addr } " " ${ !pv :- ${ dport }} "
}
PASS = 0; FAIL_N = 0; WARN_N = 0
pass() { echo -e " ${ PASS } $1 " ; PASS = $(( PASS + 1 )) ; }
fail() { echo -e " ${ FAIL } $1 " ; FAIL_N = $(( FAIL_N + 1 )) ; }
warn() { echo -e " ${ WARN } $1 " ; WARN_N = $(( WARN_N + 1 )) ; }
info() { echo -e " ${ INFO } $1 " ; }
# ── Keycloak helper ──────────────────────────────────────────────────────────
kc_admin_token() {
curl -sf -X POST " ${ KC } /realms/master/protocol/openid-connect/token" \
-d client_id = admin-cli -d "username= ${ KEYCLOAK_ADMIN :- admin } " \
-d "password= ${ KEYCLOAK_ADMIN_PASSWORD :- } " -d grant_type = password 2>/dev/null |
python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo ""
}
kc_get() { curl -sf -H "Authorization: Bearer ${ KC_TOKEN } " " ${ KC } /admin/realms/ ${ REALM } $1 " 2>/dev/null; }
# client_credentials → cetak "OK" atau "HTTP<code>" atau "ERR".
cc_grant() { # <client_id> <secret> → "OK" / "HTTP<code>"
local code
code = " $( curl -s -o /dev/null -w '%{http_code}' -X POST \
" ${ KC } /realms/ ${ REALM } /protocol/openid-connect/token" \
-d "client_id= $1 " -d "client_secret= $2 " -d grant_type = client_credentials 2>/dev/null || true ) "
code = " ${ code :- 000 } "
[[ " ${ code } " == "200" ]] && echo OK || echo "HTTP ${ code } "
}
# Uji NYATA redirect_uri via endpoint /auth (publik — tak perlu admin): apakah Keycloak
# MENERIMA redirect (→ halaman/redirect login) atau MENOLAK ("Invalid parameter: redirect_uri").
kc_auth_probe() { # <client_id> <redirect_uri> → OK | REJECTED | HTTP<code>
local out code
out = " $( curl -s -w $'\n%{http_code}' -G " ${ KC } /realms/ ${ REALM } /protocol/openid-connect/auth" \
--data-urlencode "client_id= $1 " --data-urlencode "redirect_uri= $2 " \
--data-urlencode "response_type=code" --data-urlencode "scope=openid" 2>/dev/null || true ) "
code = " ${ out ##* $'\n' } "
if printf '%s' " ${ out } " | grep -qi 'Invalid parameter: redirect_uri' || [[ " ${ code } " == "400" ]] ; then
echo REJECTED; return
fi
case " ${ code } " in 200| 302| 303) echo OK ;; *) echo "HTTP ${ code :- 000 } " ;; esac
}
# password grant → cetak "preferred_username|roles" (dari id_token) atau "".
user_login() { # <user> <pw>
curl -s -X POST " ${ KC } /realms/ ${ REALM } /protocol/openid-connect/token" \
-d client_id = admin-cli -d "username= $1 " -d "password= $2 " \
-d grant_type = password -d scope = openid 2>/dev/null |
python3 -c "
import sys,json,base64
try: r=json.load(sys.stdin)
except Exception: print(''); raise SystemExit
t=r.get('id_token') or r.get('access_token') or ''
if not t: print(''); raise SystemExit
p=t.split('.')[1]; p+='='*(-len(p)%4)
try: c=json.loads(base64.urlsafe_b64decode(p))
except Exception: print(''); raise SystemExit
print(c.get('preferred_username','') + '|' + ','.join(c.get('realm_access',{}).get('roles') or []))
" 2>/dev/null || echo ""
}
# ── A. Realm & roles ─────────────────────────────────────────────────────────
check_realm_roles() {
local disc issuer
disc = " $( curl -sf " ${ KC } /realms/ ${ REALM } /.well-known/openid-configuration" 2>/dev/null || true ) "
if [[ -z " ${ disc } " ]] ; then
fail "OIDC discovery realm ' ${ REALM } ' tidak merespon ( ${ KC } )" ; return
fi
issuer = " $( echo " ${ disc } " | python3 -c "import sys,json;print(json.load(sys.stdin).get('issuer',''))" 2>/dev/null) "
pass "OIDC discovery OK (issuer= ${ issuer } )"
if [[ -z " ${ KC_TOKEN } " ]] ; then
warn "Tanpa admin Keycloak — cek statis (roles/client/user) DILEWATI" ; return
fi
local roles missing = ""
roles = " $( kc_get "/roles" | python3 -c "import sys,json;print(' '.join(r['name'] for r in json.load(sys.stdin)))" 2>/dev/null || echo "" ) "
for r in " ${ REQUIRED_ROLES [@] } " ; do [[ " ${ roles } " == *" ${ r } " * ]] || missing += " ${ r } " ; done
[[ -z " ${ missing } " ]] && pass "Roles lengkap: ${ REQUIRED_ROLES [*] } " || fail "Role belum ada: ${ missing } "
}
# ── B. Clients (statis) ──────────────────────────────────────────────────────
check_clients() {
[[ -n " ${ KC_TOKEN } " ]] || return 0
local cid js ok
# Bila origin host TAK BISA dihitung (env tanpa alamat cluster) → beri tahu SEKALI;
# cek origin per client di-skip (bukan PASS palsu). Sumber benar: --env-file bundle
# atau SICABE_CLIENT_REDIRECT_ORIGINS.
local _c _have = 0
[[ -n " ${ SICABE_CLIENT_REDIRECT_ORIGINS :- } " ]] && _have = 1
for _c in " ${ REQUIRED_CLIENTS [@] } " ; do [[ -n " $( expected_origin " ${ _c } " ) " ]] && { _have = 1; break; } ; done
[[ ${ _have } -eq 0 ]] && warn "Origin host tak bisa dihitung (env tanpa alamat cluster) — jalankan dgn --env-file generated/host-<nama>/.env, atau set SICABE_CLIENT_REDIRECT_ORIGINS. Cek origin DILEWATI (bukan PASS)."
for cid in " ${ REQUIRED_CLIENTS [@] } " ; do
js = " $( kc_get "/clients?clientId= ${ cid } " ) "
if [[ -z " ${ js } " || " ${ js } " == "{}" || " ${ js } " == "[]" ]] ; then
fail "Client ' ${ cid } ' TIDAK ADA" ; continue
fi
# Ambil bidang penting + secret + redirect + mapper dalam satu pass.
read -r ok pub std svc auth secret redirects mapper < <( echo " ${ js } " | python3 -c "
import sys,json
d=json.load(sys.stdin) or []
if not d: print('0 - - - - - - -'); raise SystemExit
c=d[0]
print(
'1' if c.get('enabled') else '0',
c.get('publicClient'),
'1' if c.get('standardFlowEnabled') else '0',
'1' if c.get('serviceAccountsEnabled') else '0',
c.get('clientAuthenticatorType',''),
c.get('secret',''),
','.join(c.get('redirectUris') or []),
','.join(c.get('redirectUris') or []))
" 2>/dev/null)
[[ " ${ ok } " == "1" ]] && pass "Client ' ${ cid } ' enabled" || fail "Client ' ${ cid } ' DISABLED"
[[ " ${ pub } " == "False" ]] && pass " ${ cid } : publicClient=false (confidential)" \
|| fail " ${ cid } : publicClient= ${ pub } (harus False)"
[[ " ${ std } " == "1" ]] && pass " ${ cid } : standardFlowEnabled" || fail " ${ cid } : standardFlow OFF"
[[ " ${ svc } " == "1" ]] && pass " ${ cid } : serviceAccountsEnabled" || warn " ${ cid } : serviceAccounts OFF"
[[ " ${ auth } " == "client-secret" ]] && pass " ${ cid } : clientAuthenticatorType=client-secret" \
|| fail " ${ cid } : authenticator=' ${ auth } ' (harus client-secret)"
# Secret sinkron dgn env.
if [[ -n " ${ CLIENT_SECRET [ $cid ] } " ]] ; then
[[ " ${ secret } " == " ${ CLIENT_SECRET [ $cid ] } " ]] && pass " ${ cid } : secret sinkron dgn env" \
|| fail " ${ cid } : secret BEDA dari env (komponen akan gagal login)"
else
warn " ${ cid } : *_CLIENT_SECRET kosong di env — tak bisa membandingkan secret"
fi
# redirectUris: localhost + (bila ada) PUBLIC_URL.
if [[ " ${ redirects } " == *"http://localhost:*" * || " ${ redirects } " == *"https://localhost:*" * ]] ; then
pass " ${ cid } : redirectUris memuat localhost"
else
warn " ${ cid } : redirectUris TANPA localhost ( ${ redirects } )"
fi
local puburl = " ${ CLIENT_PUBLIC [ $cid ] } "
if [[ -n " ${ puburl } " ]] ; then
if [[ " ${ redirects } " == *" ${ puburl } " * ]] ; then
pass " ${ cid } : redirectUris memuat PUBLIC_URL ( ${ puburl } )"
else
fail " ${ cid } : redirectUris TANPA PUBLIC_URL ' ${ puburl } ' (login browser via alamat publik akan ditolak)"
fi
fi
# Origin host (tempat browser membuka komponen) — WAJIB ada, bukan hanya localhost.
local exp; exp = " $( expected_origin " ${ cid } " ) "
if [[ -n " ${ exp } " ]] ; then
if [[ " ${ redirects } " == *" ${ exp } " * ]] ; then
pass " ${ cid } : redirectUris memuat origin host ( ${ exp } )"
else
fail " ${ cid } : redirectUris TANPA origin host ' ${ exp } ' — login browser via alamat itu DITOLAK (invalid redirect_uri)"
fi
fi
# Origin client dari CLUSTER (SICABE_CLIENT_REDIRECT_ORIGINS) — host existing yang
# menjalankan bootstrap tak tahu alamat host cluster, jadi daftar ini disuplai ke .env.
local o
for o in ${ SICABE_CLIENT_REDIRECT_ORIGINS :- } ; do
[[ -n " ${ o } " ]] || continue
if [[ " ${ redirects } " == *" ${ o } " * ]] ; then
pass " ${ cid } : redirectUris memuat origin cluster ( ${ o } )"
else
fail " ${ cid } : redirectUris TANPA origin cluster ' ${ o } '"
fi
done
done
}
# ── C. Users (statis) + password default ─────────────────────────────────────
check_users() {
[[ -n " ${ KC_TOKEN } " ]] || return 0
local u js
for u in " ${ REQUIRED_USERS [@] } " ; do
js = " $( kc_get "/users?username= ${ u } &exact=true" ) "
if [[ -z " ${ js } " || " ${ js } " == "[]" ]] ; then fail "User ' ${ u } ' TIDAK ADA" ; continue ; fi
local uid enabled roles
uid = " $( echo " ${ js } " | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['id'] if d else '')" 2>/dev/null) "
enabled = " $( echo " ${ js } " | python3 -c "import sys,json;d=json.load(sys.stdin);print('1' if d and d[0].get('enabled') else '0')" 2>/dev/null) "
roles = " $( kc_get "/users/ ${ uid } /role-mappings/realm" | python3 -c "import sys,json;print(' '.join(r['name'] for r in json.load(sys.stdin)))" 2>/dev/null || echo "" ) "
[[ " ${ enabled } " == "1" ]] && pass "User ' ${ u } ' ada + enabled" || fail "User ' ${ u } ' DISABLED"
[[ " ${ roles } " == *" ${ USER_ROLE [ $u ] } " * ]] && pass " ${ u } : realm role ' ${ USER_ROLE [ $u ] } '" \
|| fail " ${ u } : role ' ${ USER_ROLE [ $u ] } ' TIDAK ada (peran: ${ roles :- — } )"
# Password masih DEFAULT (hardcoded)?
local dp = " ${ USER_DEFAULT_PW [ $u ] } "
if [[ -n " ${ dp } " ]] && [[ " $( user_login " ${ u } " " ${ dp } " ) " == " ${ u } |" * ]] ; then
warn " ${ u } : password masih DEFAULT (hardcoded) — ganti untuk produksi"
fi
done
}
# ── D. client_credentials ────────────────────────────────────────────────────
check_cc() {
local cid res
for cid in " ${ REQUIRED_CLIENTS [@] } " ; do
[[ -n " ${ CLIENT_SECRET [ $cid ] } " ]] || { warn "cc ${ cid } : secret env kosong" ; continue ; }
res = " $( cc_grant " ${ cid } " " ${ CLIENT_SECRET [ $cid ] } " ) "
[[ " ${ res } " == "OK" ]] && pass "client_credentials ' ${ cid } ' → token OK" \
|| fail "client_credentials ' ${ cid } ' GAGAL ( ${ res } )"
done
}
# ── E2. Redirect URI — validasi NYATA via /auth (bukan sekadar bandingkan daftar) ──
check_redirect_validation() {
local cid o exp rs any = 0
for cid in " ${ REQUIRED_CLIENTS [@] } " ; do
exp = " $( expected_origin " ${ cid } " ) "
if [[ -n " ${ exp } " ]] ; then
any = 1
rs = " $( kc_auth_probe " ${ cid } " " ${ exp } /" ) "
case " ${ rs } " in
OK) pass " ${ cid } : Keycloak MENERIMA redirect ' ${ exp } /*' (uji nyata via /auth)" ;;
REJECTED) fail " ${ cid } : Keycloak MENOLAK redirect ' ${ exp } /*' — invalid redirect_uri (belum terdaftar di client)" ;;
*) warn " ${ cid } : uji redirect ' ${ exp } /*' tak konklusif ( ${ rs } )" ;;
esac
fi
done
# Origin dari daftar cluster (SICABE_CLIENT_REDIRECT_ORIGINS) — uji terhadap tiap client.
for o in ${ SICABE_CLIENT_REDIRECT_ORIGINS :- } ; do
[[ -n " ${ o } " ]] || continue
any = 1
for cid in " ${ REQUIRED_CLIENTS [@] } " ; do
rs = " $( kc_auth_probe " ${ cid } " " ${ o } /" ) "
[[ " ${ rs } " == "OK" ]] && pass " ${ cid } : Keycloak MENERIMA origin cluster ' ${ o } /*'" \
|| fail " ${ cid } : Keycloak MENOLAK origin cluster ' ${ o } /*' ( ${ rs } )"
done
done
if [[ ${ any } -eq 0 ]] ; then
warn "redirect_uri tak bisa diuji (env tanpa alamat cluster / SICABE_CLIENT_REDIRECT_ORIGINS)"
return
fi
# Kontrol: redirect ngawur HARUS ditolak → membuktikan Keycloak memang memvalidasi.
rs = " $( kc_auth_probe "trino" "https://sicabe-invalid.example/blocked" ) "
[[ " ${ rs } " == "REJECTED" ]] \
&& pass "kontrol: redirect ngawur DITOLAK Keycloak (validasi redirect aktif)" \
|| warn "kontrol: redirect ngawur ' ${ rs } ' — Keycloak mungkin TIDAK memvalidasi redirect_uri"
}
# ── E. Login user (password grant) ───────────────────────────────────────────
check_user_login() {
local u out pref
for u in " ${ REQUIRED_USERS [@] } " ; do
local pw = " ${ USER_DEFAULT_PW [ $u ] } "
[[ -n " ${ pw } " ]] || { warn "login ' ${ u } ': password tak diketahui — lewati" ; continue ; }
out = " $( user_login " ${ u } " " ${ pw } " ) "
pref = " ${ out %%|* } "
if [[ " ${ pref } " == " ${ u } " ]] ; then
pass "login ' ${ u } ' OK (preferred_username= ${ pref } ; roles= ${ out #*| } )"
elif [[ -z " ${ out } " ]] ; then
fail "login ' ${ u } ' GAGAL (user/password salah, atau user belum ada)"
else
fail "login ' ${ u } ' OK tapi preferred_username=' ${ pref } ' (harus = uid LDAP ' ${ u } ')"
fi
done
}
# ── main ─────────────────────────────────────────────────────────────────────
echo -e " ${ BOLD } Verifikasi Keycloak — realm ' ${ REALM } ' @ ${ KC }${ NC } "
echo ""
KC_TOKEN = " $( kc_admin_token) "
if [[ -z " ${ KC_TOKEN } " ]] ; then
warn "Mode READ-ONLY (login admin gagal / tanpa admin) — cek statis dilewati"
else
info "Token admin diperoleh — cek statis dijalankan"
fi
echo ""
echo -e " ${ BOLD } A. Realm & roles ${ NC } " ; check_realm_roles
echo -e " ${ BOLD } B. Clients (statis) ${ NC } " ; check_clients
echo -e " ${ BOLD } C. Users (statis) ${ NC } " ; check_users
echo -e " ${ BOLD } D. Fungsional — client_credentials (uji SECRET client, bukan redirect) ${ NC } " ; check_cc
echo -e " ${ BOLD } E. Fungsional — login user ${ NC } " ; check_user_login
echo -e " ${ BOLD } F. Redirect URI — validasi NYATA via /auth ${ NC } " ; check_redirect_validation
echo ""
echo -e " ${ BOLD } Ringkasan: ${ PASS } PASS · ${ FAIL_N } FAIL · ${ WARN_N } WARN ${ NC } "
[[ ${ FAIL_N } -eq 0 ]] && { echo -e " ${ GREEN } ✓ Keycloak siap. ${ NC } " ; exit 0; }
echo -e " ${ RED } ✗ Ada ${ FAIL_N } kegagalan — perbaiki sebelum melanjutkan. ${ NC } "
exit 1