#!/usr/bin/env bash # ============================================================================= # test-authz.sh — uji model, CLI, dan kebijakan OPA (authorization) # ----------------------------------------------------------------------------- # Yang dibuktikan (bukan sekadar "berkasnya ada"): # 1. data.json SINKRON dengan authz.yml (hasil render), & render round-trip. # 2. Policy Rego TIDAK lagi meng-hardcode nama catalog/schema (data-driven). # 3. Mutasi CLI (role/grant/user) pada salinan model bekerja & bolak-balik bersih. # 4. `--dry-run` benar-benar tidak menulis. # 5. Kebijakan OPA (dievaluasi dengan OPA asli via docker) mempertahankan # perilaku lama DAN mengganti-ganti catalog lewat data (uji rename). # Bagian ini DILEWATI bila docker tak tersedia. # # Jalankan: ./scripts/test-authz.sh # ============================================================================= set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" CLI="${SCRIPT_DIR}/sicabe-authz" MODEL="${DEPLOY_DIR}/config/opa/authz.yml" DATA="${DEPLOY_DIR}/config/opa/data.json" REGO="${DEPLOY_DIR}/config/opa/policies/trino.rego" OUT="$(mktemp -d)" trap 'rm -rf "${OUT}"' EXIT GRN=$'\e[32m'; RED=$'\e[31m'; BLD=$'\e[1m'; NC=$'\e[0m' PASS_N=0; FAIL_N=0; SKIP_N=0 ok() { printf " ${GRN}✅${NC} %s\n" "$1"; PASS_N=$((PASS_N + 1)); } bad() { printf " ${RED}❌${NC} %s\n" "$1"; FAIL_N=$((FAIL_N + 1)); } skip() { printf " ${BLD}…${NC} %s\n" "$1"; SKIP_N=$((SKIP_N + 1)); } echo -e "${BLD}Uji authorization (model authz.yml + CLI + policy OPA)${NC}" echo # ─── A. Model & render ─────────────────────────────────────────────────────── echo -e "${BLD}A. Model & render${NC}" [[ -f "${MODEL}" ]] && ok "authz.yml ada" || bad "authz.yml tidak ada" if python3 "${DEPLOY_DIR}/lib/authz.py" --out "${OUT}/data.json" render >/dev/null 2>&1; then ok "render authz.yml -> data.json berhasil" else bad "render gagal (model tidak valid?)" fi if "${CLI}" check >/dev/null 2>&1; then ok "data.json SINKRON dengan authz.yml" else bad "data.json TIDAK sinkron (jalankan: sicabe-authz render)" fi if python3 - "${DATA}" "${OUT}/data.json" <<'PY' import json, sys a = json.load(open(sys.argv[1])); b = json.load(open(sys.argv[2])) sys.exit(0 if a == b else 1) PY then ok "data.json identik dengan hasil render"; else bad "data.json berbeda dari hasil render"; fi # ─── B. Policy data-driven ─────────────────────────────────────────────────── echo; echo -e "${BLD}B. Policy Rego data-driven${NC}" if grep -q 'data.authz.catalogs' "${REGO}" && grep -q 'schema_grant' "${REGO}"; then ok "Rego data-driven (data.authz.catalogs + schema_grant)" else bad "Rego belum data-driven (data.authz.* / schema_grant tidak ditemukan)" fi if grep -qE '"iceberg"' "${REGO}"; then bad "Rego masih meng-hardcode literal \"iceberg\"" else ok "Rego bebas literal katalog \"iceberg\"" fi # ─── C. Mutasi CLI pada salinan model ──────────────────────────────────────── echo; echo -e "${BLD}C. Mutasi CLI (salinan model)${NC}" M="${OUT}/authz.yml"; cp "${MODEL}" "${M}" cli() { "${CLI}" --model "${M}" "$@"; } roles_out="$("${CLI}" list roles)" case "${roles_out}" in *data-engineer*) ok "list roles memuat data-engineer" ;; *) bad "list roles keliru" ;; esac "${CLI}" matrix --schema silver >/dev/null 2>&1 && ok "matrix --schema berjalan" || bad "matrix gagal" "${CLI}" --model "${M}" role add data-scientist >/dev/null 2>&1 "${CLI}" --model "${M}" grant add data-scientist gold.orders_gold --caps select --columns id,total_value >/dev/null 2>&1 out="$(cli show role data-scientist)" case "${out}" in *"gold.orders_gold"*"caps=[select]"*) ok "grant add tersimpan (select, kolom)" ;; *) bad "grant add tidak tersimpan: ${out}" ;; esac "${CLI}" --model "${M}" grant set data-scientist gold.orders_gold --caps select,write --columns "*" >/dev/null 2>&1 out="$(cli show role data-scientist)" case "${out}" in *"caps=[select,write]"*) ok "grant set menimpa caps" ;; *) bad "grant set gagal: ${out}" ;; esac "${CLI}" --model "${M}" grant remove data-scientist gold.orders_gold --caps write >/dev/null 2>&1 out="$(cli show role data-scientist)" case "${out}" in *"caps=[select]"*) ok "grant remove --caps menyisakan select" ;; *) bad "grant remove gagal: ${out}" ;; esac "${CLI}" --model "${M}" grant add data-scientist 'gold.*' --caps select --columns "*" >/dev/null 2>&1 out="$(cli show role data-scientist)" case "${out}" in *"gold.*"*) ok "grant tingkat-schema tersimpan (gold.*)" ;; *) bad "grant schema tak tersimpan: ${out}" ;; esac python3 "${DEPLOY_DIR}/lib/authz.py" --model "${M}" --out "${OUT}/d2.json" render >/dev/null 2>&1 if python3 -c "import json;d=json.load(open('${OUT}/d2.json'));assert d['roles']['data-scientist']['schema_grants']['gold']['select'] is True"; then ok "render menampilkan schema_grants" else bad "render tak memuat schema_grants" fi "${CLI}" --model "${M}" grant remove data-scientist 'gold.*' >/dev/null 2>&1 out="$(cli show role data-scientist)" case "${out}" in *"gold.*"*) bad "grant schema tak terhapus" ;; *) ok "grant schema terhapus" ;; esac "${CLI}" --model "${M}" user assign svc_ml data-scientist >/dev/null 2>&1 out="$(cli show user svc_ml)" case "${out}" in *"svc_ml: data-scientist"*) ok "user assign bekerja" ;; *) bad "user assign gagal: ${out}" ;; esac "${CLI}" --model "${M}" user unassign svc_ml >/dev/null 2>&1 "${CLI}" --model "${M}" role remove data-scientist >/dev/null 2>&1 python3 -c "import yaml,sys; sys.exit(0 if 'data-scientist' not in yaml.safe_load(open('${M}'))['roles'] else 1)" \ && ok "role remove membersihkan model" || bad "role remove gagal" # error & dry-run if "${CLI}" --model "${M}" grant add data-nope bronze.orders --caps select >/dev/null 2>&1; then bad "grant ke role tak ada seharusnya GAGAL" else ok "grant ke role tak ada ditolak" fi "${CLI}" --dry-run --model "${M}" role add data-ghost >/dev/null 2>&1 grep -q 'data-ghost' "${M}" && bad "--dry-run MENULIS berkas" || ok "--dry-run tidak menulis" # ─── D. Grup/user LDAP (read-only; dilewati bila LDAP tak terjangkau) ──────── echo; echo -e "${BLD}D. Grup & user LDAP${NC}" if python3 - "${DEPLOY_DIR}/lib/authz_ldap.py" <<'PY' import importlib.util, sys spec = importlib.util.spec_from_file_location("authz_ldap", sys.argv[1]) m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) e = m.parse_ldif("dn: cn=x,dc=a\ncn: x\nmember: uid=1\nmember: uid=2\n\ndn: cn=y,dc=a\ncn: y\n") assert e[0]["cn"] == "x" and e[0]["member"] == ["uid=1", "uid=2"] and e[1]["cn"] == "y", e PY then ok "parser LDIF benar (parse_ldif)"; else bad "parser LDIF salah"; fi svc_out="$("${CLI}" user list --service 2>/dev/null)" case "${svc_out}" in *svc_dbt*) ok "user list --service (model) bekerja" ;; *) bad "user list --service keliru" ;; esac grp_out="$("${CLI}" group list 2>/dev/null)" if [[ -n "${grp_out}" ]]; then ok "group list mengembalikan grup dari LDAP (${grp_out//$'\n'/,})" else skip "LDAP tak terjangkau — lewati uji grup live" fi # ─── E. Policy OPA (OPA asli via docker) ──────────────────────────────────── echo; echo -e "${BLD}E. Evaluasi policy OPA (docker)${NC}" if docker info >/dev/null 2>&1; then if python3 - "${DEPLOY_DIR}" <<'PY' import json, os, shutil, subprocess, sys, tempfile from pathlib import Path dep = Path(sys.argv[1]); opa = dep / "config" / "opa"; IMG = "openpolicyagent/opa:1.20.2" def build(cases=None, variant=None): ctx = Path(tempfile.mkdtemp()) shutil.copytree(opa / "policies", ctx / "policies") data = json.loads((opa / "data.json").read_text()) if variant: data.update(variant) (ctx / "data.json").write_text(json.dumps(data)) dockerfile = f"FROM {IMG}\nCOPY policies /cfg/policies\nCOPY data.json /cfg/data.json\n" if cases: (ctx / "cases").mkdir() for i, c in enumerate(cases): (ctx / "cases" / f"{i}.json").write_text(json.dumps(c[0])) dockerfile += "COPY cases /cfg/cases\n" (ctx / "Dockerfile").write_text(dockerfile) tag = f"sicabe-authz-test:{os.path.basename(ctx)}" subprocess.run(["docker", "build", "-t", tag, str(ctx)], check=True, capture_output=True) return tag def ev(tag, i=None): cmd = ["docker", "run", "--rm", "--user", "0", tag, "eval", "-d", "/cfg/policies", "-d", "/cfg/data.json", "--format", "raw"] if i is not None: cmd += ["-i", f"/cfg/cases/{i}.json"] cmd.append("data.trino.allow") return subprocess.run(cmd, capture_output=True, text=True).stdout.strip() def tin(op, cat, sch, tbl, cols, user, groups): return {"action": {"operation": op, "resource": {"table": {"catalogName": cat, "schemaName": sch, "tableName": tbl, "columns": cols}}}, "context": {"identity": {"user": user, "groups": groups}}} base_cases = [ ({}, "false"), # deny-by-default (tin("DROPTABLE", "iceberg", "gold", "orders_gold", [], "admin", ["data-admin"]), "true"), (tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["id", "total_value"], "u", ["data-analyst"]), "true"), (tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["id", "order_date"], "u", ["data-analyst"]), "false"), ] ren_cases = [ (tin("SELECTFROMCOLUMNS", "lakehouse", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "true"), (tin("SELECTFROMCOLUMNS", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "false"), ] fails = 0 tb = build(base_cases) if subprocess.run(["docker", "run", "--rm", "--user", "0", tb, "check", "/cfg/policies"], capture_output=True).returncode != 0: print(" opa check GAGAL"); fails += 1 for i, (_, want) in enumerate(base_cases): got = ev(tb, i) print(f" keputusan[{i}] -> {got or '(undefined)'} (harap {want})") if got != want: fails += 1 # Blok luas data-driven: op di LUAR op_caps (simulasi level-kolom UPDATE/MERGE) extra = [ (tin("UpdateColumns", "iceberg", "bronze", "orders", ["id"], "u", ["data-engineer"]), "true"), (tin("UpdateColumns", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "false"), (tin("UpdateColumns", "iceberg", "other", "x", ["id"], "u", ["data-engineer"]), "false"), ] te = build(extra) for i, (_, want) in enumerate(extra): got = ev(te, i) print(f" luas[{i}] -> {got or '(undefined)'} (harap {want})") if got != want: fails += 1 tr = build(ren_cases, {"authz": {"catalogs": ["lakehouse"], "write_schemas": ["bronze", "silver", "gold"]}}) for i, (_, want) in enumerate(ren_cases): got = ev(tr, i) print(f" rename[{i}] -> {got or '(undefined)'} (harap {want})") if got != want: fails += 1 # Grant tingkat-schema: data-analyst dapat 'gold.*' (select) roles = json.loads((opa / "data.json").read_text())["roles"] roles["data-analyst"]["schema_grants"] = {"gold": {"select": True, "columns": "*"}} sch_cases = [ (tin("SELECTFROMCOLUMNS", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "true"), (tin("SELECTFROMCOLUMNS", "iceberg", "gold", "brand_new_table", ["id"], "u", ["data-analyst"]), "true"), (tin("SELECTFROMCOLUMNS", "iceberg", "bronze", "orders", ["id"], "u", ["data-analyst"]), "false"), (tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["order_date"], "u", ["data-analyst"]), "false"), (tin("FILTERTABLES", "iceberg", "gold", "whatever", [], "u", ["data-analyst"]), "true"), ] ts = build(sch_cases, {"roles": roles}) for i, (_, want) in enumerate(sch_cases): got = ev(ts, i) print(f" schema[{i}] -> {got or '(undefined)'} (harap {want})") if got != want: fails += 1 sys.exit(1 if fails else 0) PY then ok "policy OPA: perilaku dasar + ganti catalog lewat data (via docker)" else bad "policy OPA: ada keputusan yang tidak sesuai" fi else skip "docker tak tersedia — lewati evaluasi policy OPA" fi # ─── Ringkasan ─────────────────────────────────────────────────────────────── echo echo " skor: ${PASS_N} lulus · ${FAIL_N} gagal · ${SKIP_N} dilewati" if [[ ${FAIL_N} -eq 0 ]]; then echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} cek)" exit 0 fi echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} masalah · ${PASS_N} lulus" exit 1