add authz group
This commit is contained in:
+60
-7
@@ -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 <uid>")
|
||||
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 <cn>")
|
||||
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":
|
||||
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
|
||||
|
||||
|
||||
@@ -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 <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
|
||||
+47
-28
@@ -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 <perintah> [opsi] [--dry-run] [--env-file <f>]
|
||||
# --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 <s>] matriks role x tabel (s=select w=write d=ddl)
|
||||
show role|user|table <nama> detail satu objek
|
||||
grant add|set|remove <role> <schema.table> [--caps a,b] [--columns x,y|*]
|
||||
role add|remove <nama> [--superuser] [--force]
|
||||
user assign|unassign <svc> <role> service account -> role (model)
|
||||
catalog list|add|rename <baru> 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 <cn> --member <uid> buat grup (butuh >=1 member)
|
||||
group remove <cn> hapus grup
|
||||
group rename <lama> <baru> rename LDAP + role OPA + permitted_groups StarRocks
|
||||
group add-member|remove-member <cn> <uid>
|
||||
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 <uid> [--cn <n>] [--mail <m>] [--password <p>] [--in <group>]
|
||||
user remove <uid> hapus user (ikut keluar dari semua grup)
|
||||
|
||||
Umum: --dry-run (di posisi mana pun) · --env-file <path> · -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user