Files
2026-10-13 15:05:04 +07:00

355 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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