#!/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 "$@"