#!/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