Files
sicabe-core/deploy/lib/authz_ldap.py
T

355 lines
14 KiB
Python
Raw Normal View History

2026-10-13 15:05:04 +07:00
#!/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 <uid>")
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