diff --git a/deploy/lib/authz.py b/deploy/lib/authz.py index 8d0285d..97d95e7 100644 --- a/deploy/lib/authz.py +++ b/deploy/lib/authz.py @@ -25,6 +25,8 @@ from pathlib import Path import yaml +import authz_ldap as L + CAPS = ("select", "write", "ddl") DEPLOY_DIR = Path(__file__).resolve().parent.parent DEFAULT_MODEL = DEPLOY_DIR / "config" / "opa" / "authz.yml" @@ -425,21 +427,54 @@ def cmd_role(args, model_path: Path) -> int: def cmd_user(args, model_path: Path) -> int: model = load_model(model_path) - users = model.setdefault("users", {}) + users_ = model.setdefault("users", {}) if args.action == "assign": if args.role not in model.get("roles", {}): raise ModelError(f"role {args.role!r} tidak ada") - users[args.user] = args.role + users_[args.user] = args.role _say(f"assign {args.user} -> {args.role}") else: - if args.user not in users: + if args.user not in users_: raise ModelError(f"service account {args.user!r} tidak terdaftar") - del users[args.user] + del users_[args.user] _say(f"unassign {args.user}") save_model(model_path, model) return 0 +def cmd_user_ldap(args) -> int: + conf = L.config() + if args.action == "list": + return L.user_list(conf, args.service) + if not args.user: + raise ModelError(f"user {args.action}: butuh ") + if args.action == "add": + return L.user_add(conf, args.user, args.cn, args.mail, args.password, args.group) + return L.user_remove(conf, args.user) + + +def cmd_group(args, model_path: Path) -> int: + conf = L.config() + a = args.action + if a == "list": + return L.group_list(conf, args.members) + if a == "check": + return L.group_check(conf, model_path) + if a == "sync": + return L.group_sync(conf, model_path, args.apply) + if not args.a: + raise ModelError(f"group {a}: butuh ") + if a == "add": + return L.group_add(conf, args.a, args.member) + if a == "remove": + return L.group_remove(conf, args.a) + if not args.b: + raise ModelError(f"group {a}: butuh dua argumen") + if a == "rename": + return L.group_rename(conf, model_path, args.a, args.b) + return L.group_member(conf, args.a, args.b, a == "add-member") + + CATALOG_SCAN_PATTERNS = ("deploy/scripts", "deploy/lib", "deploy/config", "docs") @@ -562,9 +597,23 @@ def build_parser() -> argparse.ArgumentParser: pr.add_argument("--force", action="store_true") pu = sub.add_parser("user") - pu.add_argument("action", choices=["assign", "unassign"]) - pu.add_argument("user") + pu.add_argument("action", choices=["assign", "unassign", "list", "add", "remove"]) + pu.add_argument("user", nargs="?") pu.add_argument("role", nargs="?") + pu.add_argument("--service", action="store_true", help="user list: tampilkan service account (model)") + pu.add_argument("--cn") + pu.add_argument("--mail") + pu.add_argument("--password") + pu.add_argument("--in", dest="group") + + pgr = sub.add_parser("group") + pgr.add_argument("action", choices=["list", "add", "remove", "rename", + "add-member", "remove-member", "check", "sync"]) + pgr.add_argument("a", nargs="?", help="cn (atau cn lama untuk rename)") + pgr.add_argument("b", nargs="?", help="uid (atau cn baru)") + pgr.add_argument("--members", action="store_true", help="list: tampilkan anggota") + pgr.add_argument("--member", action="append", default=[], help="add: uid anggota (boleh berulang)") + pgr.add_argument("--apply", action="store_true", help="sync: tulis perubahan") pc = sub.add_parser("catalog") pc.add_argument("action", choices=["list", "add", "rename"]) @@ -591,7 +640,11 @@ def dispatch(args) -> int: if args.cmd == "role": return cmd_role(args, args.model) if args.cmd == "user": - return cmd_user(args, args.model) + if args.action in ("assign", "unassign"): + return cmd_user(args, args.model) + return cmd_user_ldap(args) + if args.cmd == "group": + return cmd_group(args, args.model) if args.cmd == "catalog": return cmd_catalog(args, args.model) raise ModelError(f"perintah tak dikenal: {args.cmd}") @@ -601,7 +654,7 @@ def main(argv: list | None = None) -> int: args = build_parser().parse_args(argv) try: return dispatch(args) - except ModelError as exc: + except (ModelError, L.LdapError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 diff --git a/deploy/lib/authz_ldap.py b/deploy/lib/authz_ldap.py new file mode 100644 index 0000000..e6820b9 --- /dev/null +++ b/deploy/lib/authz_ldap.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# ============================================================================= +# SiCABE — lib/authz_ldap.py +# ----------------------------------------------------------------------------- +# Pengelolaan GRUP & USER OpenLDAP (sumber grup Trino & StarRocks) untuk CLI +# sicabe-authz. Melengkapi model OPA: nama grup LDAP (cn) WAJIB sama dengan kunci +# `roles` di config/opa/authz.yml DAN `permitted_groups` di bootstrap.sh. +# +# Strategi tulis: SELALU coba tulis (via ldapadd/ldapmodify/ldapdelete). Bila +# gagal (mis. LDAP existing read-only), LDIF yang setara dicetak agar bisa +# diterapkan admin LDAP secara manual. +# +# Semua operasi memakai ldap-utils; bila container openldap LOKAL berjalan, +# perintah dijalankan lewat `docker exec` (tak perlu ldap-utils di host). +# ============================================================================= +from __future__ import annotations + +import os +import re +import subprocess +import sys + +DRY = os.environ.get("SICABE_AUTHZ_DRY_RUN") == "1" + + +class LdapError(Exception): + """Operasi LDAP gagal / konfigurasi tak lengkap.""" + + +def _say(msg: str) -> None: + print(f"[DRY-RUN] {msg}" if DRY else f"{msg}") + + +# ── Konfigurasi & eksekusi ────────────────────────────────────────────────── + +def config() -> dict: + return { + "container": os.environ.get("LDAP_CONTAINER_NAME", "sicabe-openldap"), + "admin_dn": os.environ.get("LDAP_ADMIN_DN", "cn=admin,dc=sicabe,dc=local"), + "password": os.environ.get("LDAP_ADMIN_PASSWORD", ""), + "host": os.environ.get("LDAP_HOST", "localhost"), + "port": os.environ.get("LDAP_PORT") or os.environ.get("OPENLDAP_PORT", "12389"), + "users_dn": os.environ.get("LDAP_USERS_DN", "ou=users,dc=sicabe,dc=local"), + "groups_dn": os.environ.get("LDAP_GROUPS_DN", "ou=groups,dc=sicabe,dc=local"), + } + + +def is_local(conf: dict) -> bool: + r = subprocess.run(["docker", "ps", "--format", "{{.Names}}"], capture_output=True, text=True) + return conf["container"] in r.stdout.split() + + +def endpoint(conf: dict) -> str: + return "ldap://localhost" if is_local(conf) else f"ldap://{conf['host']}:{conf['port']}" + + +def _run(conf: dict, args: list, stdin: str | None = None): + cmd = (["docker", "exec", "-i", conf["container"]] if is_local(conf) else []) + args + return subprocess.run(cmd, input=stdin, capture_output=True, text=True) + + +def _search(conf: dict, base: str, filt: str, attrs: list | None = None) -> list: + args = ["ldapsearch", "-LLL", "-x", "-H", endpoint(conf), "-D", conf["admin_dn"], + "-w", conf["password"], "-b", base, filt] + if attrs: + args += attrs + r = _run(conf, args) + if r.returncode != 0 and "No such object" not in r.stderr: + raise LdapError(f"ldapsearch gagal: {r.stderr.strip() or 'periksa LDAP_URL/kredensial'}") + return parse_ldif(r.stdout) + + +def _apply(conf: dict, tool: str, ldif: str, action: str) -> None: + """Jalankan ldapadd/ldapmodify/ldapdelete; bila gagal, cetak LDIF setara.""" + if DRY: + _say(f"{tool} ({action})"); print(_indent(ldif)); return + r = _run(conf, [tool, "-x", "-H", endpoint(conf), "-D", conf["admin_dn"], + "-w", conf["password"]], stdin=ldif) + if r.returncode != 0: + print(f" GAGAL: {r.stderr.strip()}", file=sys.stderr) + print(" LDIF yang setara (untuk diterapkan admin LDAP):", file=sys.stderr) + print(_indent(ldif), file=sys.stderr) + raise LdapError(f"{tool} gagal untuk {action}") + _say(f"{tool} ok: {action}") + + +def _delete(conf: dict, dn: str, action: str) -> None: + """ldapdelete TIDAK membaca LDIF dari stdin — DN diberikan sebagai argumen.""" + if DRY: + _say(f"ldapdelete ({action})"); print(_indent(f"dn: {dn}")); return + r = _run(conf, ["ldapdelete", "-x", "-H", endpoint(conf), "-D", conf["admin_dn"], + "-w", conf["password"], dn]) + if r.returncode != 0: + print(f" GAGAL: {r.stderr.strip()}", file=sys.stderr) + print(f" DN: {dn}", file=sys.stderr) + raise LdapError(f"ldapdelete gagal untuk {action}") + _say(f"ldapdelete ok: {action}") + + +def _indent(text: str) -> str: + return "\n".join(" " + ln for ln in text.strip().splitlines()) + + +def parse_ldif(text: str) -> list: + entries, cur, prev = [], {}, None + for raw in text.splitlines(): + if raw.startswith("#"): + continue + if not raw.strip(): + if cur: + entries.append(cur) + cur, prev = {}, None + continue + if raw[:1] == " " and prev: # lanjutan (folded line) + cur[prev] = cur[prev] + raw[1:] + continue + if ":" not in raw: + continue + k, _, v = raw.partition(":") + k = k.strip() + v = v.strip() + if k in cur: + if not isinstance(cur[k], list): + cur[k] = [cur[k]] + cur[k].append(v) + else: + cur[k] = v + prev = k + if cur: + entries.append(cur) + return entries + + +# ── Query LDAP ────────────────────────────────────────────────────────────── + +def groups(conf: dict) -> dict: + out = {} + for e in _search(conf, conf["groups_dn"], "(objectClass=groupOfNames)", ["cn", "member"]): + cn = e.get("cn") + if cn: + m = e.get("member", []) + out[cn] = m if isinstance(m, list) else [m] + return out + + +def users(conf: dict) -> list: + return sorted(e["uid"] for e in _search(conf, conf["users_dn"], "(objectClass=inetOrgPerson)", ["uid"]) + if e.get("uid")) + + +def _user_dn(conf: dict, uid: str) -> str: + return f"uid={uid},{conf['users_dn']}" + + +def _group_dn(conf: dict, cn: str) -> str: + return f"cn={cn},{conf['groups_dn']}" + + +def _next_number(conf: dict) -> int: + nums = [int(e["uidNumber"]) for e in _search(conf, conf["users_dn"], "(uidNumber=*)", ["uidNumber"]) + if str(e.get("uidNumber", "")).isdigit()] + return (max(nums) + 1) if nums else 1000 + + +# ── Perintah: group ───────────────────────────────────────────────────────── + +def group_list(conf: dict, with_members: bool) -> int: + for cn, members in sorted(groups(conf).items()): + print(f"{cn} ({len(members)} member)" if not with_members else cn) + if with_members: + for m in members: + print(f" {m}") + return 0 + + +def group_add(conf: dict, cn: str, member_uids: list) -> int: + if cn in groups(conf): + raise LdapError(f"grup {cn!r} sudah ada") + if not member_uids: + raise LdapError("groupOfNames butuh minimal 1 member — beri --member ") + lines = [f"dn: {_group_dn(conf, cn)}", "objectClass: groupOfNames", f"cn: {cn}"] + for uid in member_uids: + lines.append(f"member: {_user_dn(conf, uid)}") + _apply(conf, "ldapadd", "\n".join(lines) + "\n", f"buat grup {cn}") + return 0 + + +def group_remove(conf: dict, cn: str) -> int: + if cn not in groups(conf): + raise LdapError(f"grup {cn!r} tidak ada") + _delete(conf, _group_dn(conf, cn), f"hapus grup {cn}") + return 0 + + +def group_member(conf: dict, cn: str, uid: str, add: bool) -> int: + if cn not in groups(conf): + raise LdapError(f"grup {cn!r} tidak ada") + verb = "add" if add else "delete" + ldif = (f"dn: {_group_dn(conf, cn)}\nchangetype: modify\n{verb}: member\n" + f"member: {_user_dn(conf, uid)}\n") + _apply(conf, "ldapmodify", ldif, f"{'tambah' if add else 'hapus'} {uid} di {cn}") + return 0 + + +def group_rename(conf: dict, model_path, old: str, new: str) -> int: + import authz as A + if old not in groups(conf): + raise LdapError(f"grup {old!r} tidak ada") + if new in groups(conf): + raise LdapError(f"grup {new!r} sudah ada") + model = A.load_model(model_path) + if old not in model.get("roles", {}): + raise LdapError(f"role {old!r} tidak ada di {model_path} — nama grup LDAP harus sama dengan kunci roles") + # 1) LDAP: rename entry (modrdn) + ldif = (f"dn: {_group_dn(conf, old)}\nchangetype: modrdn\nnewrdn: cn={new}\n" + f"deleteoldrdn: 1\n") + _apply(conf, "ldapmodify", ldif, f"rename grup LDAP {old} -> {new}") + # 2) model OPA: ganti kunci role + roles = model["roles"] + model["roles"] = { (new if k == old else k): v for k, v in roles.items() } + A.save_model(model_path, model) + _say(f"authz.yml: roles {old} -> {new}") + # 3) StarRocks permitted_groups di bootstrap.sh + _update_permitted_groups(old, new) + return 0 + + +# ── Perintah: user ────────────────────────────────────────────────────────── + +def user_list(conf: dict, service: bool) -> int: + if service: + import authz as A + for u, r in (A.load_model(A.DEFAULT_MODEL).get("users", {}) or {}).items(): + print(f"{u} -> {r} (service account)") + return 0 + for uid in users(conf): + print(uid) + return 0 + + +def user_add(conf: dict, uid: str, cn: str | None, mail: str | None, + password: str | None, group: str | None) -> int: + if uid in users(conf): + raise LdapError(f"user {uid!r} sudah ada") + n = _next_number(conf) + lines = [f"dn: {_user_dn(conf, uid)}", "objectClass: inetOrgPerson", "objectClass: posixAccount", + f"uid: {uid}", f"cn: {cn or uid}", f"sn: {cn or uid}", f"mail: {mail or uid + '@sicabe.local'}", + f"uidNumber: {n}", f"gidNumber: {n}", f"homeDirectory: /home/{uid}"] + if password: + lines.append(f"userPassword: {password}") + _apply(conf, "ldapadd", "\n".join(lines) + "\n", f"buat user {uid}") + if group: + group_member(conf, group, uid, add=True) + print(f" catatan: pastikan user Keycloak dengan username '{uid}' ada (auth = Keycloak OIDC).") + return 0 + + +def user_remove(conf: dict, uid: str) -> int: + if uid not in users(conf): + raise LdapError(f"user {uid!r} tidak ada") + dn = _user_dn(conf, uid) + for cn, members in groups(conf).items(): + if dn in members: + group_member(conf, cn, uid, add=False) + _delete(conf, dn, f"hapus user {uid}") + return 0 + + +# ── Konsistensi: grup LDAP == roles OPA == permitted_groups StarRocks ──────── + +BS_RE = re.compile(r"('permitted_groups'\s*=\s*')([^']*)(')") + + +def _bootstrap_path() -> str: + # Bisa ditimpa (untuk pengujian): SICABE_BOOTSTRAP=/path/ke/bootstrap.sh + return os.environ.get("SICABE_BOOTSTRAP") or os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bootstrap.sh") + + +def permitted_groups() -> list: + with open(_bootstrap_path(), encoding="utf-8") as fh: + for line in fh: + m = BS_RE.search(line) + if m: + return [g for g in m.group(2).split(",") if g] + return [] + + +def _update_permitted_groups(old: str, new: str) -> None: + path = _bootstrap_path() + with open(path, encoding="utf-8") as fh: + text = fh.read() + if f"'{old}'" not in text and f",{old}," not in text and not re.search(rf"\b{re.escape(old)}\b", text): + return + new_text = BS_RE.sub(lambda m: m.group(1) + ",".join(new if g == old else g for g in m.group(2).split(",")) + m.group(3), text, count=1) + if new_text == text: + print(f" (permitted_groups tidak memuat {old})") + return + if DRY: + _say(f"bootstrap.sh: permitted_groups {old} -> {new}") + else: + with open(path, "w", encoding="utf-8") as fh: + fh.write(new_text) + print(f" bootstrap.sh: permitted_groups {old} -> {new}") + + +def group_check(conf: dict, model_path) -> int: + import authz as A + roles = set(A.load_model(model_path).get("roles", {})) + cns = set(groups(conf)) + permitted = set(permitted_groups()) + problems = 0 + for c in sorted(cns - roles): + print(f" ❌ grup LDAP {c!r} tidak punya role di authz.yml"); problems += 1 + for g in sorted(permitted - roles): + print(f" ❌ permitted_groups memuat {g!r} yang bukan role"); problems += 1 + if cns != permitted: + only_cn, only_pg = sorted(cns - permitted), sorted(permitted - cns) + if only_cn: + print(f" ❌ grup LDAP tanpa permitted_groups: {only_cn}"); problems += 1 + if only_pg: + print(f" ❌ permitted_groups tanpa grup LDAP: {only_pg}"); problems += 1 + no_group = sorted(roles - cns) + if no_group: + print(f" ℹ role tanpa grup LDAP (mis. khusus service account): {no_group}") + if problems == 0: + print(f" ✅ konsisten: grup LDAP == permitted_groups ⊆ roles ({len(cns)} grup)") + else: + print(f" {problems} masalah · perbaiki dgn: sicabe-authz group sync / group add") + return 1 if problems else 0 + + +def group_sync(conf: dict, model_path, apply: bool) -> int: + import authz as A + roles = set(A.load_model(model_path).get("roles", {})) + cns = sorted(groups(conf)) + for c in cns: + if c not in roles: + print(f" ❌ grup LDAP {c!r} tanpa role di authz.yml — tambahkan: sicabe-authz role add {c}") + # permitted_groups StarRocks = daftar GRUP LDAP (bukan semua role). Setara -> biarkan. + if set(permitted_groups()) == set(cns): + print(f" permitted_groups sudah sinkron ({','.join(permitted_groups())})") + return 0 + path = _bootstrap_path() + with open(path, encoding="utf-8") as fh: + text = fh.read() + new_text = BS_RE.sub(lambda m: m.group(1) + ",".join(cns) + m.group(3), text, count=1) + if apply and not DRY: + with open(path, "w", encoding="utf-8") as fh: + fh.write(new_text) + print(f" bootstrap.sh: permitted_groups -> {','.join(cns)}") + else: + _say(f"bootstrap.sh: permitted_groups -> {','.join(cns)}") + return 0 diff --git a/deploy/scripts/sicabe-authz b/deploy/scripts/sicabe-authz index 7dfbbc4..f389cea 100755 --- a/deploy/scripts/sicabe-authz +++ b/deploy/scripts/sicabe-authz @@ -2,53 +2,72 @@ # ============================================================================= # eBdesk SiCABE — sicabe-authz # ----------------------------------------------------------------------------- -# CLI pengelolaan AUTHORIZATION (OPA/Trino) berbasis model config/opa/authz.yml. +# CLI pengelolaan AUTHORIZATION: model OPA/Trino (config/opa/authz.yml) dan +# GRUP/USER OpenLDAP (sumber grup Trino & StarRocks). Logika: lib/authz.py + +# lib/authz_ldap.py (Python + PyYAML; ldap-utils). # -# Model authz.yml = SUMBER KEBENARAN. Perintah di sini mengedit model, lalu -# merender data ACL OPA (config/opa/data.json) dan (opsional) memuat ulang OPA. -# Logika model ada di lib/authz.py (Python + PyYAML). -# -# Pemakaian: -# ./scripts/sicabe-authz list roles -# ./scripts/sicabe-authz matrix [--schema bronze] -# ./scripts/sicabe-authz show role data-engineer -# ./scripts/sicabe-authz grant set data-analyst gold.orders_gold --caps select --columns id -# ./scripts/sicabe-authz role add data-scientist -# ./scripts/sicabe-authz user assign svc_dbt data-engineer -# ./scripts/sicabe-authz catalog rename iceberg lakehouse -# ./scripts/sicabe-authz render # authz.yml -> data.json -# ./scripts/sicabe-authz check # pastikan data.json sinkron -# ./scripts/sicabe-authz apply [--no-reload] # render + reload OPA -# ./scripts/sicabe-authz reload # recreate container OPA -# -# --dry-run (posisi mana pun) -> jangan tulis apa pun, hanya cetak rencana. +# Pemakaian: ./scripts/sicabe-authz [opsi] [--dry-run] [--env-file ] +# --dry-run (posisi mana pun) -> jangan tulis apa pun, cetak rencana. # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=../lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" 2>/dev/null || true +# shellcheck source=../config/environment.defaults.sh +source "${SCRIPT_DIR}/config/environment.defaults.sh" 2>/dev/null || true OPA_CONTAINER="${OPA_CONTAINER_NAME:-sicabe-opa}" +PY="${SCRIPT_DIR}/lib/authz.py" usage() { - sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + cat <<'EOF' +sicabe-authz — kelola authorization (OPA/Trino) + grup/user OpenLDAP + +MODEL OPA (authz.yml -> data.json): + list catalogs|roles|users|tables daftar isi model + matrix [--schema ] matriks role x tabel (s=select w=write d=ddl) + show role|user|table detail satu objek + grant add|set|remove [--caps a,b] [--columns x,y|*] + role add|remove [--superuser] [--force] + user assign|unassign service account -> role (model) + catalog list|add|rename kelola catalog (rename ikut berkas Trino) + fmt | render | check rapikan / render / cek sinkron + apply [--no-reload] | reload render (+ reload OPA) + +GRUP & USER LDAP (sumber grup Trino & StarRocks): + group list [--members] daftar grup (+ anggota) + group add --member buat grup (butuh >=1 member) + group remove hapus grup + group rename rename LDAP + role OPA + permitted_groups StarRocks + group add-member|remove-member + group check cek roles == grup LDAP == permitted_groups + group sync [--apply] selaraskan permitted_groups (+ saran grup hilang) + user list [--service] daftar user LDAP (atau service account) + user add [--cn ] [--mail ] [--password

] [--in ] + user remove hapus user (ikut keluar dari semua grup) + +Umum: --dry-run (di posisi mana pun) · --env-file · -h/--help +EOF } -# ── Ambil --dry-run dari posisi mana pun ───────────────────────────────────── -ARGS=() +# ── Ekstrak --dry-run dan --env-file dari posisi mana pun ──────────────────── +ARGS=(); ENVFILE=""; prev="" for a in "$@"; do - if [[ "${a}" == "--dry-run" ]]; then - export SICABE_AUTHZ_DRY_RUN=1 - else - ARGS+=("${a}") - fi + if [[ "${prev}" == "--env-file" ]]; then ENVFILE="${a}"; prev=""; continue; fi + case "${a}" in + --dry-run) export SICABE_AUTHZ_DRY_RUN=1 ;; + --env-file) prev="--env-file" ;; + *) ARGS+=("${a}") ;; + esac done +[[ -n "${ENVFILE}" ]] && export SICABE_ENV_FILE="${ENVFILE}" set -- ${ARGS[@]+"${ARGS[@]}"} [[ $# -eq 0 || "${1}" == "-h" || "${1}" == "--help" || "${1}" == "help" ]] && { usage; exit 0; } -PY="${SCRIPT_DIR}/lib/authz.py" +# Muat .env (LDAP_* dsb). --env-file sudah diteruskan via SICABE_ENV_FILE. +sicabe_load_env "${SCRIPT_DIR}" # ── reload: recreate container OPA agar memuat policy+data terbaru ─────────── reload_opa() { diff --git a/deploy/scripts/test-authz.sh b/deploy/scripts/test-authz.sh index 17c1b81..54e93f2 100755 --- a/deploy/scripts/test-authz.sh +++ b/deploy/scripts/test-authz.sh @@ -103,8 +103,27 @@ 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. Policy OPA (OPA asli via docker) ──────────────────────────────────── -echo; echo -e "${BLD}D. Evaluasi policy OPA (docker)${NC}" +# ─── 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