Files
sicabe-core/deploy/scripts/verify-phase1-iam.sh
T

479 lines
20 KiB
Bash
Raw Normal View History

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
KC="$(sicabe_local_url http KEYCLOAK_HOST "${KEYCLOAK_PORT:-12080}" KEYCLOAK_URL)"
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 []
print(c.get('preferred_username',''), '|', ','.join(roles))
" 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}")
preferred="${claim%% |*}"
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 "$@"