2026-10-09 02:35:14 +07:00
#!/usr/bin/env bash
# =============================================================================
# eBdesk SiCABE — Verifikasi Phase 1 (IAM): Keycloak + OpenLDAP
#
# Gate "kontrak IAM": memastikan konfigurasi Keycloak & LDAP benar-benar SIAP
# dipakai fase berikutnya (Trino+OPA, StarRocks, Airflow, OpenMetadata, Grafana)
# — termasuk saat memakai Keycloak/LDAP EXISTING (bukan container deploy ini).
#
# Yang diperiksa:
# A. Keycloak statis : realm, roles, 5 OIDC client, audience mapper, redirect URI
# B. Keycloak fungsional : login tiap user → preferred_username & roles
# C. LDAP : ou=users/ou=groups + pemetaan user→grup
# (filter PERSIS seperti config/trino/group-provider.properties)
# D. Cross-check : user Keycloak == uid LDAP ;
# grup LDAP == roles di config/opa/data.json == permitted_groups StarRocks
# E. Client secret : nilai tersimpan di Keycloak == nilai env deploy
#
# Exit code: 0 = semua gate lolos (WARN boleh ada), 1 = ada FAIL.
#
# Usage:
# ./scripts/verify-phase1-iam.sh
# # menunjuk IAM existing:
# KEYCLOAK_URL=http://kc.corp:8080 LDAP_HOST=ldap.corp LDAP_PORT=389 \
# ./scripts/verify-phase1-iam.sh
# =============================================================================
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 } " " $@ "
# Config RELEVAN: mode host (--env-file) = BUNDLE host (ter-render), bukan deploy/config/ sumber.
if [[ -n " ${ SICABE_BUNDLE_DIR :- } " ]] ; then CONFIG_DIR = " ${ SICABE_BUNDLE_DIR } /config" ; else CONFIG_DIR = " ${ SCRIPT_DIR } /config" ; fi
2026-10-11 18:13:03 +07:00
KC = " $( sicabe_keycloak_url) "
2026-10-09 02:35:14 +07:00
REALM = " ${ SICABE_REALM :- sicabe } "
LDAP_HOST = " ${ LDAP_HOST :- localhost } "
LDAP_PORT = " ${ LDAP_PORT :- 389 } "
LDAP_ADMIN_DN = " ${ LDAP_ADMIN_DN :- cn =admin,dc=sicabe,dc=local } "
LDAP_BASE_DN = " ${ LDAP_BASE_DN :- dc =sicabe,dc=local } "
USERS_DN = " ${ LDAP_USERS_DN :- ou =users, ${ LDAP_BASE_DN }} "
GROUPS_DN = " ${ LDAP_GROUPS_DN :- ou =groups, ${ LDAP_BASE_DN }} "
REQUIRED_CLIENTS =( "trino" "starrocks" "airflow" "openmetadata" "grafana" )
REQUIRED_ROLES =( "admin" "engineer" "analyst" "viewer" )
REQUIRED_USERS =( "admin" "engineer" "analyst" "viewer" )
AUDIENCE_MAPPER = "audience-account"
PASS_N = 0
FAIL_N = 0
WARN_N = 0
pass() { echo -e " ${ PASS } $1 " ; PASS_N = $(( PASS_N + 1 )) ; }
FAIL_MSGS =()
fail() { echo -e " ${ FAIL } $1 " ; FAIL_N = $(( FAIL_N + 1 )) ; FAIL_MSGS +=( " $1 " ) ; }
warn() { echo -e " ${ WARN } $1 " ; WARN_N = $(( WARN_N + 1 )) ; }
# ─────────────────────────────────────────────────────────────────────────────
# Keycloak — helper
# ─────────────────────────────────────────────────────────────────────────────
kc_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
}
kc_user_login_probe() {
# → "preferred_username|roles". Membaca **id_token** (scope=openid): sejak
# Keycloak 26 access token bersifat "lightweight" sehingga tidak lagi memuat
# preferred_username / realm_access.
local user = " $1 " pw = " $2 "
curl -s -X POST " ${ KC } /realms/ ${ REALM } /protocol/openid-connect/token" \
-d client_id = admin-cli -d "username= ${ user } " -d "password= ${ pw } " \
-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
tok=r.get('id_token') or r.get('access_token') or ''
if not tok:
print(''); raise SystemExit
p=tok.split('.')[1]; p+='='*(-len(p)%4)
try:
c=json.loads(base64.urlsafe_b64decode(p))
except Exception:
print(''); raise SystemExit
roles=c.get('realm_access',{}).get('roles') or []
2026-10-11 13:59:47 +07:00
print(c.get('preferred_username','') + '|' + ','.join(roles))
2026-10-09 02:35:14 +07:00
" 2>/dev/null || echo ""
}
# Deteksi "lightweight access token" (Keycloak 26+): access token tanpa
# preferred_username. Tidak fatal, tetapi perlu diketahui karena komponen yang
# membaca klaim dari ACCESS token (bukan id_token) harus menyesuaikan.
kc_access_token_is_lightweight() {
local user = " $1 " pw = " $2 "
curl -s -X POST " ${ KC } /realms/ ${ REALM } /protocol/openid-connect/token" \
-d client_id = admin-cli -d "username= ${ user } " -d "password= ${ pw } " \
-d grant_type = password 2>/dev/null |
python3 -c "
import sys,json,base64
try:
t=json.load(sys.stdin).get('access_token','')
p=t.split('.')[1]; p+='='*(-len(p)%4)
c=json.loads(base64.urlsafe_b64decode(p))
except Exception:
print('unknown'); raise SystemExit
print('lightweight' if 'preferred_username' not in c else 'full')
" 2>/dev/null || echo "unknown"
}
check_keycloak_static() {
echo -e " ${ BOLD } A. Keycloak — statis (realm, roles, client, mapper) ${ NC } "
local discovery
discovery = $( curl -sf " ${ KC } /realms/ ${ REALM } /.well-known/openid-configuration" 2>/dev/null || echo "" )
if [[ -n " ${ discovery } " ]] ; then
pass "OIDC discovery realm ' ${ REALM } ' OK ( $( echo " ${ discovery } " | python3 -c "import sys,json;print(json.load(sys.stdin).get('issuer',''))" 2>/dev/null) )"
else
fail "OIDC discovery realm ' ${ REALM } ' tidak merespon — cek ${ KC } /realms/ ${ REALM } "
return 1
fi
if [[ " ${ HAVE_ADMIN :- 0 } " -eq 0 ]] ; then
warn "Tanpa admin Keycloak — cek realm/roles/client/mapper DILEWATI (didelegasikan ke admin IdP)."
return 0
fi
local roles
roles = $( kc_get "/roles" | python3 -c "import sys,json;print(' '.join(r['name'] for r in json.load(sys.stdin)))" 2>/dev/null || echo "" )
local r missing_roles = ""
for r in " ${ REQUIRED_ROLES [@] } " ; do
[[ " ${ roles } " == *" ${ r } " * ]] || missing_roles += " ${ r } "
done
if [[ -z " ${ missing_roles } " ]] ; then
pass "Roles lengkap: ${ REQUIRED_ROLES [*] } "
else
fail "Role belum ada: ${ missing_roles } "
fi
local c cid mappers
for c in " ${ REQUIRED_CLIENTS [@] } " ; do
cid = $( kc_get "/clients?clientId= ${ c } " | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['id'] if d else '')" 2>/dev/null || echo "" )
if [[ -z " ${ cid } " ]] ; then
fail "Client OIDC ' ${ c } ' tidak ada"
continue
fi
mappers = $( kc_get "/clients/ ${ cid } /protocol-mappers/models" | python3 -c "import sys,json;print(' '.join(m['name'] for m in json.load(sys.stdin)))" 2>/dev/null || echo "" )
if [[ " ${ mappers } " == *" ${ AUDIENCE_MAPPER } " * ]] ; then
pass "Client ' ${ c } ' ada + audience mapper ' ${ AUDIENCE_MAPPER } '"
else
fail "Client ' ${ c } ' TANPA audience mapper ' ${ AUDIENCE_MAPPER } ' (Trino OIDC akan menolak token)"
fi
done
local sr_redirect
sr_redirect = $( kc_get "/clients?clientId=starrocks" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(' '.join(d[0].get('redirectUris',[])) if d else '')" 2>/dev/null || echo "" )
if [[ " ${ sr_redirect } " == *"starrocks-fe:8030" * ]] ; then
pass "Client 'starrocks' punya redirect URI internal FE"
else
warn "Client 'starrocks' belum memuat redirect internal (http://starrocks-fe:8030/api/oauth2)"
fi
return 0
}
check_client_secrets() {
echo -e " ${ BOLD } E. Client secret — Keycloak vs env deploy ${ NC } "
if [[ " ${ HAVE_ADMIN :- 0 } " -eq 0 ]] ; then
warn "Tanpa admin Keycloak — cek secret via Admin API DILEWATI (diganti cek client_credentials §F)."
return 0
fi
local pairs =( "trino: ${ TRINO_CLIENT_SECRET } " "starrocks: ${ STARROCKS_CLIENT_SECRET } "
"airflow: ${ AIRFLOW_CLIENT_SECRET } " "openmetadata: ${ OPENMETADATA_CLIENT_SECRET } "
"grafana: ${ GRAFANA_CLIENT_SECRET } " )
local pair cid expected actual bad = 0
for pair in " ${ pairs [@] } " ; do
cid = " ${ pair %%:* } "
expected = " ${ pair #*: } "
actual = $( kc_get "/clients?clientId= ${ cid } " | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(d[0].get('secret','') if d else '')" 2>/dev/null || echo "" )
if [[ -z " ${ actual } " ]] ; then
warn "Client ' ${ cid } ' tidak ditemukan — lewati cek secret"
elif [[ " ${ actual } " == " ${ expected } " ]] ; then
pass "Secret ' ${ cid } ' sinkron dengan env deploy"
else
fail "Secret ' ${ cid } ' BEDA dari env deploy (komponen akan gagal login)"
bad = $(( bad + 1 ))
fi
done
[[ ${ bad } -eq 0 ]] || warn "Samakan secret di .env dengan nilai di Keycloak."
}
check_keycloak_users_login() {
echo -e " ${ BOLD } B. Keycloak — fungsional (login tiap user) ${ NC } "
local user pw claim preferred
for user in " ${ REQUIRED_USERS [@] } " ; do
case " ${ user } " in
admin) pw = " ${ KEYCLOAK_ADMIN_PASSWORD } " ;;
*) pw = " ${ user } 123" ;;
esac
claim = $( kc_user_login_probe " ${ user } " " ${ pw } " )
2026-10-11 13:59:47 +07:00
preferred = " ${ claim %%|* } "
2026-10-09 02:35:14 +07:00
if [[ " ${ preferred } " == " ${ user } " ]] ; then
pass "Login ' ${ user } ' OK (preferred_username= ${ preferred } )"
elif [[ -n " ${ preferred } " ]] ; then
fail "Login ' ${ user } ' OK tapi preferred_username=' ${ preferred } ' — harus sama dengan uid LDAP"
elif [[ " ${ HAVE_ADMIN :- 0 } " -eq 0 ]] ; then
warn "Login ' ${ user } ' tak bisa diverifikasi (password user dikelola admin IdP) — dilewati"
else
fail "Login ' ${ user } ' gagal (password realm di luar default → isi manual)"
fi
done
local lt
lt = $( kc_access_token_is_lightweight "engineer" "engineer123" )
if [[ " ${ lt } " == "lightweight" ]] ; then
warn "Access token 'lightweight' (Keycloak 26): tanpa preferred_username/realm_access di access token."
warn " Komponen yang membaca klaim dari access token harus memakai id_token / userinfo."
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# LDAP — helper (container lokal atau LDAP existing)
# ─────────────────────────────────────────────────────────────────────────────
ldap_is_local_container() {
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^ ${ LDAP_CONTAINER_NAME :- sicabe -openldap } $"
}
ldap_endpoint() {
if ldap_is_local_container; then
echo "ldap://localhost"
else
echo "ldap:// ${ LDAP_HOST } : ${ LDAP_PORT } "
fi
}
ldap_run() {
if ldap_is_local_container; then
docker exec -i " ${ LDAP_CONTAINER_NAME :- sicabe -openldap } " " $@ "
else
" $@ "
fi
}
ldap_search() {
ldap_run ldapsearch -LLL -x -H " $( ldap_endpoint) " \
-D " ${ LDAP_ADMIN_DN } " -w " ${ LDAP_ADMIN_PASSWORD } " " $@ " 2>/dev/null
}
# Catatan: JANGAN menyalurkan keluaran ldap_search ke `grep -q`. Dengan
# `set -o pipefail`, `grep -q` yang berhenti lebih awal membuat `docker exec`
# kena SIGPIPE (exit 141) sehingga pipa dilaporkan gagal padahal cocok.
# Karena itu hasil ditampung dulu ke variabel.
ldap_entry_exists() {
local base = " $1 " filter = " $2 " attr = " ${ 3 :- cn } " out
out = $( ldap_search -b " ${ base } " -s one " ${ filter } " " ${ attr } " )
[[ -n " ${ out } " ]]
}
ldap_dn_exists() {
local out
out = $( ldap_search -b " $1 " -s base "(objectClass=*)" "*" )
[[ -n " ${ out } " ]]
}
check_ldap_structure() {
echo -e " ${ BOLD } C. LDAP — struktur & pemetaan user→grup ${ NC } "
if ! ldap_is_local_container && ! command -v ldapsearch >/dev/null 2>& 1; then
fail "ldapsearch tidak ada di host (mode LDAP existing). Install ldap-utils."
return 1
fi
local result
result = $( ldap_search -b " ${ LDAP_BASE_DN } " -s one "(objectClass=*)" dn)
if [[ -n " ${ result } " ]] ; then
pass "LDAP terjangkau di $( ldap_endpoint) — base ' ${ LDAP_BASE_DN } '"
else
fail "LDAP tidak terjangkau / bind gagal di $( ldap_endpoint) "
return 1
fi
local u g dn
for u in " ${ REQUIRED_USERS [@] } " ; do
dn = "uid= ${ u } , ${ USERS_DN } "
if ldap_dn_exists " ${ dn } " ; then
pass "User LDAP ' ${ u } ' ada"
else
fail "User LDAP ' ${ u } ' tidak ada ( ${ dn } ) — Trino tidak akan menemukan grupnya"
fi
done
local user_groups
for u in " ${ REQUIRED_USERS [@] } " ; do
user_groups = $( ldap_search -b " ${ GROUPS_DN } " "(member=uid= ${ u } , ${ USERS_DN } )" cn |
sed -n 's/^cn: //p' | tr '\n' ',' | sed 's/,$//' )
if [[ -n " ${ user_groups } " ]] ; then
pass " ${ u } → ${ user_groups } "
else
fail " ${ u } tidak berada di grup mana pun (otorisasi akan selalu ditolak)"
fi
done
for g in data-admin data-engineer data-analyst data-viewer; do
ldap_entry_exists " ${ GROUPS_DN } " "(cn= ${ g } )" cn ||
warn "Grup LDAP ' ${ g } ' tidak ada"
done
}
# ─────────────────────────────────────────────────────────────────────────────
# D. Cross-check antar-sistem
# ─────────────────────────────────────────────────────────────────────────────
check_cross_reference() {
echo -e " ${ BOLD } D. Cross-check antar-sistem ${ NC } "
if [[ " ${ HAVE_ADMIN :- 0 } " -eq 1 ]] ; then
local kc_users
kc_users = $( kc_get "/users?max=200" |
python3 -c "import sys,json;print(' '.join(sorted(u['username'] for u in json.load(sys.stdin))))" 2>/dev/null || echo "" )
local u ldap_missing = ""
for u in " ${ REQUIRED_USERS [@] } " ; do
[[ " ${ kc_users } " == *" ${ u } " * ]] || ldap_missing += " ${ u } "
done
if [[ -z " ${ ldap_missing } " ]] ; then
pass "User Keycloak ⊇ user wajib ( ${ REQUIRED_USERS [*] } )"
else
fail "User Keycloak tidak punya: ${ ldap_missing } — tidak akan bisa login OIDC"
fi
else
warn "Tanpa admin Keycloak — cek daftar user DILEWATI (verifikasi user lewat login §B)."
fi
local opa_roles
opa_roles = $( python3 -c "
import json
print(' '.join(sorted(json.load(open(' ${ CONFIG_DIR } /opa/data.json'))['roles'])))" 2>/dev/null || echo "" )
if [[ -n " ${ opa_roles } " ]] ; then
pass "Roles di OPA (data.json): ${ opa_roles } "
else
fail "Tidak bisa membaca roles dari config/opa/data.json"
fi
# Role dapat diberikan lewat dua jalur: (a) grup LDAP dengan cn sama, atau
# (b) pemetaan service account di data.users (untuk akun yang tidak ada di LDAP).
local svc_roles
svc_roles = $( python3 -c "
import json
print(' '.join(sorted(set(json.load(open(' ${ CONFIG_DIR } /opa/data.json')).get('users', {}).values()))))" 2>/dev/null || echo "" )
local g no_ldap = "" svc_only = ""
for g in ${ opa_roles } ; do
if ldap_entry_exists " ${ GROUPS_DN } " "(cn= ${ g } )" cn; then
continue
fi
if [[ " ${ svc_roles } " == *" ${ g } " * ]] ; then
svc_only += " ${ g } "
else
no_ldap += " ${ g } "
fi
done
if [[ -z " ${ no_ldap } " ]] ; then
pass "Semua role OPA dapat diberikan (via grup LDAP atau pemetaan service account)"
[[ -n " ${ svc_only } " ]] && pass " role khusus service account: ${ svc_only } "
else
fail "Role OPA tanpa grup LDAP & tanpa pemetaan service: ${ no_ldap } "
fi
local sr_groups
sr_groups = $( grep -oE "'permitted_groups' = '[^']+'" " ${ SCRIPT_DIR } /bootstrap.sh" 2>/dev/null |
head -1 | sed "s/.*= '//; s/'//" | tr ',' ' ' )
if [[ -n " ${ sr_groups } " ]] ; then
local grp mismatch = ""
for grp in ${ sr_groups } ; do
[[ " ${ opa_roles } " == *" ${ grp } " * ]] || mismatch += " ${ grp } "
done
if [[ -z " ${ mismatch } " ]] ; then
pass "permitted_groups StarRocks ⊆ roles OPA"
else
warn "permitted_groups StarRocks memuat grup yang tidak ada di OPA: ${ mismatch } "
fi
else
warn "Tidak bisa membaca permitted_groups StarRocks dari bootstrap.sh"
fi
}
# F. client_credentials — TANPA admin: membuktikan klien ADA + secret SINKRON +
# audience mapper jalan (aud=account). Ini pengganti cek statis §A/§E saat IAM
# dikelola pihak lain (tidak ada akses admin). Butuh serviceAccountsEnabled=true.
check_client_credentials() {
echo -e " ${ BOLD } F. Keycloak — client_credentials (klien + secret + audience; tanpa admin) ${ NC } "
local pairs =( "trino: ${ TRINO_CLIENT_SECRET } " "starrocks: ${ STARROCKS_CLIENT_SECRET } "
"airflow: ${ AIRFLOW_CLIENT_SECRET } " "openmetadata: ${ OPENMETADATA_CLIENT_SECRET } "
"grafana: ${ GRAFANA_CLIENT_SECRET } " )
local pair cid secret tok aud
for pair in " ${ pairs [@] } " ; do
cid = " ${ pair %%:* } " ; secret = " ${ pair #*: } "
tok = $( curl -s -X POST " ${ KC } /realms/ ${ REALM } /protocol/openid-connect/token" \
-d grant_type = client_credentials -d "client_id= ${ cid } " -d "client_secret= ${ secret } " 2>/dev/null |
python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "" )
if [[ -z " ${ tok } " ]] ; then
fail "client_credentials ' ${ cid } ' GAGAL (klien tak ada / secret beda / service account OFF)"
continue
fi
aud = $( printf '%s' " ${ tok } " | python3 -c "
import sys,json,base64
t=sys.stdin.read().strip(); p=t.split('.')[1]; p+='='*(-len(p)%4)
d=json.loads(base64.urlsafe_b64decode(p)); a=d.get('aud') or []
print(','.join(a if isinstance(a,list) else [a]))" 2>/dev/null || echo "" )
if [[ " ${ aud } " == *" account " * ]] ; then
pass "client_credentials ' ${ cid } ' OK + aud=account (mapper audience ada, secret sinkron)"
else
warn "client_credentials ' ${ cid } ' OK tapi aud tanpa 'account' (aud=' ${ aud } ') — cek audience mapper"
fi
done
}
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
main() {
print_section "Verifikasi Phase 1 — Kontrak IAM (Keycloak + LDAP)"
echo -e " Keycloak : ${ CYAN }${ KC }${ NC } (realm ${ REALM } )"
echo -e " LDAP : ${ CYAN } $( ldap_endpoint) ${ NC } "
echo ""
KC_TOKEN = " $( kc_token) "
HAVE_ADMIN = 1
if [[ -z " ${ KC_TOKEN } " ]] ; then
HAVE_ADMIN = 0
echo -e " ${ WARN } Tanpa akses admin Keycloak ( ${ KC } ) — lanjut mode READ-ONLY."
echo -e " ${ INFO } Cek statis realm/role/user/client di Keycloak DILEWATI (didelegasikan ke admin IdP)."
echo -e " ${ INFO } Tetap diperiksa: OIDC discovery · client_credentials (klien ada + secret sinkron +"
echo -e " ${ INFO } aud=account) · LDAP · cross-check lokal (OPA/StarRocks)."
fi
check_keycloak_static
check_keycloak_users_login
check_client_credentials
check_client_secrets
check_ldap_structure
check_cross_reference
echo ""
echo -e " ${ BOLD } Ringkasan: ${ NC } ${ GREEN }${ PASS_N } PASS ${ NC } · ${ RED }${ FAIL_N } FAIL ${ NC } · ${ YELLOW }${ WARN_N } WARN ${ NC } "
if [[ ${ FAIL_N } -gt 0 ]] ; then
echo ""
echo -e " ${ BOLD } Yang gagal ( ${ FAIL_N } ): ${ NC } "
local _i = 1 _m
for _m in " ${ FAIL_MSGS [@] } " ; do
echo -e " ${ RED }${ _i } . ${ NC } ${ _m } "
_i = $(( _i + 1 ))
done
echo -e " ${ INFO } Perbaiki item di atas, lalu jalankan ulang: ./scripts/ ${ 0 ##*/ } "
fi
if [[ ${ FAIL_N } -eq 0 ]] ; then
echo -e " ${ OK } ${ GREEN } Kontrak IAM Phase 1 terpenuhi — aman lanjut ke fase berikutnya. ${ NC } "
exit 0
fi
echo -e " ${ ERR } Ada ${ FAIL_N } gate yang gagal — perbaiki sebelum melanjutkan."
exit 1
}
main " $@ "