feat sicabe-authz

This commit is contained in:
2026-10-13 14:44:55 +07:00
parent 082de8e3b2
commit f534e8da54
6 changed files with 1030 additions and 17 deletions
+58
View File
@@ -0,0 +1,58 @@
# =============================================================================
# SiCABE — Model Authorization (SUMBER KEBENARAN) — OPA / Trino
# -----------------------------------------------------------------------------
# Berkas ini SATU-SATUNYA sumber kebenaran ACL. JANGAN mengedit
# config/opa/data.json manual — itu HASIL RENDER:
# ./scripts/sicabe-authz render # authz.yml -> data.json
# ./scripts/sicabe-authz apply # render + reload OPA
# ./scripts/sicabe-authz fmt # rapikan format berkas ini
# Policy Rego membaca data.authz.catalogs / data.authz.write_schemas — nama
# catalog/schema TIDAK di-hardcode di Rego. Ganti nama catalog = ubah `catalogs:`
# di sini -> render -> reload. Dokumentasi: docs/authorization-cli.md
# =============================================================================
version: 1
catalogs: [iceberg]
schemas: [bronze, silver, gold]
roles:
data-admin: {superuser: true}
data-engineer:
grants:
- table: bronze.orders
caps: [select, write, ddl]
columns: '*'
- table: bronze.orders_incremental
caps: [select, write, ddl]
columns: '*'
- table: silver.orders_silver
caps: [select, write, ddl]
columns: '*'
- table: gold.orders_gold
caps: [select, write, ddl]
columns: '*'
data-catalog:
grants:
- table: bronze.orders
caps: [select]
columns: '*'
- table: bronze.orders_incremental
caps: [select]
columns: '*'
- table: silver.orders_silver
caps: [select]
columns: '*'
- table: silver.orders_clean
caps: [select]
columns: '*'
- table: gold.orders_gold
caps: [select]
columns: '*'
data-analyst:
grants:
- table: gold.orders_gold
caps: [select]
columns: '*'
- table: silver.orders_silver
caps: [select]
columns: [id, total_value]
data-viewer: {}
users: {svc_dbt: data-engineer, svc_airflow: data-engineer, svc_seed: data-engineer, svc_om: data-catalog}
+69 -11
View File
@@ -1,29 +1,83 @@
{ {
"authz": {
"catalogs": [
"iceberg"
],
"write_schemas": [
"bronze",
"silver",
"gold"
]
},
"roles": { "roles": {
"data-admin": { "data-admin": {
"superuser": true "superuser": true
}, },
"data-engineer": { "data-engineer": {
"tables": { "tables": {
"bronze_orders": { "select": true, "write": true, "ddl": true, "columns": "*" }, "bronze_orders": {
"bronze_orders_incremental": { "select": true, "write": true, "ddl": true, "columns": "*" }, "select": true,
"silver_orders_silver": { "select": true, "write": true, "ddl": true, "columns": "*" }, "write": true,
"gold_orders_gold": { "select": true, "write": true, "ddl": true, "columns": "*" } "ddl": true,
"columns": "*"
},
"bronze_orders_incremental": {
"select": true,
"write": true,
"ddl": true,
"columns": "*"
},
"silver_orders_silver": {
"select": true,
"write": true,
"ddl": true,
"columns": "*"
},
"gold_orders_gold": {
"select": true,
"write": true,
"ddl": true,
"columns": "*"
}
} }
}, },
"data-catalog": { "data-catalog": {
"tables": { "tables": {
"bronze_orders": { "select": true, "columns": "*" }, "bronze_orders": {
"bronze_orders_incremental": { "select": true, "columns": "*" }, "select": true,
"silver_orders_silver": { "select": true, "columns": "*" }, "columns": "*"
"silver_orders_clean": { "select": true, "columns": "*" }, },
"gold_orders_gold": { "select": true, "columns": "*" } "bronze_orders_incremental": {
"select": true,
"columns": "*"
},
"silver_orders_silver": {
"select": true,
"columns": "*"
},
"silver_orders_clean": {
"select": true,
"columns": "*"
},
"gold_orders_gold": {
"select": true,
"columns": "*"
}
} }
}, },
"data-analyst": { "data-analyst": {
"tables": { "tables": {
"gold_orders_gold": { "select": true, "columns": "*" }, "gold_orders_gold": {
"silver_orders_silver": { "select": true, "columns": ["id", "total_value"] } "select": true,
"columns": "*"
},
"silver_orders_silver": {
"select": true,
"columns": [
"id",
"total_value"
]
}
} }
}, },
"data-viewer": {} "data-viewer": {}
@@ -33,5 +87,9 @@
"svc_airflow": "data-engineer", "svc_airflow": "data-engineer",
"svc_seed": "data-engineer", "svc_seed": "data-engineer",
"svc_om": "data-catalog" "svc_om": "data-catalog"
},
"_generated": {
"from": "authz.yml",
"sha256": "8c7de053a5683e00c27f8d92157463c65ff6d45d5802e1e03b48bb9a2a933cb5"
} }
} }
+22 -6
View File
@@ -10,6 +10,9 @@
# #
# Grup user berasal dari LDAP group provider Trino (OpenLDAP, cn = data-*). # Grup user berasal dari LDAP group provider Trino (OpenLDAP, cn = data-*).
# Izin didefinisikan sebagai data di data.json → data.roles.<grup>.tables.<schema_table>. # Izin didefinisikan sebagai data di data.json → data.roles.<grup>.tables.<schema_table>.
# Nama catalog/schema TIDAK di-hardcode di policy: dibaca dari data.authz.catalogs /
# data.authz.write_schemas (hasil render config/opa/authz.yml) → rename catalog/schema
# cukup mengubah data, bukan Rego. Lihat scripts/sicabe-authz.
# grant = { select: bool, write: bool, ddl: bool, columns: "*" | [..] } # grant = { select: bool, write: bool, ddl: bool, columns: "*" | [..] }
# - data-admin : superuser (semua operasi) # - data-admin : superuser (semua operasi)
# - select : SELECT/SHOW COLUMNS/DESCRIBE # - select : SELECT/SHOW COLUMNS/DESCRIBE
@@ -30,6 +33,19 @@ op := upper(replace(replace(trim_space(input.action.operation), " ", ""), "_", "
identity_groups := input.context.identity.groups identity_groups := input.context.identity.groups
identity_user := input.context.identity.user identity_user := input.context.identity.user
# ── Nama catalog/schema dari DATA (authz.yml), JANGAN di-hardcode di sini ────
# `default` → aman (deny-by-default) bila data.authz belum ada. Rujuk path
# SPESIFIK (data.authz.*) — JANGAN meneruskan `data` utuh ke builtin, sebab itu
# membuat compiler menganggapnya bergantung pada `allow` (rego_recursion_error).
default data_catalogs := []
default write_schemas := []
data_catalogs := data.authz.catalogs
write_schemas := data.authz.write_schemas
# Catalog yang boleh di-listing = catalog terdaftar + 'system' (bawaan Trino).
allowed_catalogs contains c if { some c in data_catalogs }
allowed_catalogs contains "system"
# Role efektif = grup (dari LDAP) ∪ role user (data.users) — untuk service account # Role efektif = grup (dari LDAP) ∪ role user (data.users) — untuk service account
# yang tidak terdaftar di LDAP (mis. svc_dbt, svc_om dari password file Basic). # yang tidak terdaftar di LDAP (mis. svc_dbt, svc_om dari password file Basic).
effective_roles contains r if { effective_roles contains r if {
@@ -105,12 +121,12 @@ allow if {
allow if { allow if {
op in {"ACCESSCATALOG", "FILTERCATALOGS"} op in {"ACCESSCATALOG", "FILTERCATALOGS"}
res := input.action.resource res := input.action.resource
res.catalog.name in {"iceberg", "system"} res.catalog.name in allowed_catalogs
} }
allow if { allow if {
op == "FILTERSCHEMAS" op == "FILTERSCHEMAS"
res := input.action.resource res := input.action.resource
res.schema.catalogName in {"iceberg", "system"} res.schema.catalogName in allowed_catalogs
} }
allow if { allow if {
op == "FILTERTABLES" op == "FILTERTABLES"
@@ -163,7 +179,7 @@ cols_all_ok(columns, cols) if {
allow if { allow if {
op == "SELECTFROMCOLUMNS" op == "SELECTFROMCOLUMNS"
res := input.action.resource res := input.action.resource
res.table.catalogName == "iceberg" res.table.catalogName in data_catalogs
tid := table_id(res.table.schemaName, res.table.tableName) tid := table_id(res.table.schemaName, res.table.tableName)
cols := res.table.columns cols := res.table.columns
count(cols) > 0 count(cols) > 0
@@ -180,7 +196,7 @@ allow if {
allow if { allow if {
op == "SELECTFROMCOLUMNS" op == "SELECTFROMCOLUMNS"
res := input.action.resource res := input.action.resource
res.table.catalogName == "iceberg" res.table.catalogName in data_catalogs
tid := table_id(res.table.schemaName, res.table.tableName) tid := table_id(res.table.schemaName, res.table.tableName)
count(res.table.columns) == 0 count(res.table.columns) == 0
some r in effective_roles some r in effective_roles
@@ -234,7 +250,7 @@ any_cap(grant) if {
allow if { allow if {
op == "FILTERTABLES" op == "FILTERTABLES"
res := input.action.resource res := input.action.resource
res.table.catalogName == "iceberg" res.table.catalogName in data_catalogs
tid := table_id(res.table.schemaName, res.table.tableName) tid := table_id(res.table.schemaName, res.table.tableName)
some r in effective_roles some r in effective_roles
grant := data.roles[r].tables[tid] grant := data.roles[r].tables[tid]
@@ -264,7 +280,7 @@ allow if {
# eksplisit. Layer lain (silver/gold) TIDAK terpengaruh — tetap least-privilege. # eksplisit. Layer lain (silver/gold) TIDAK terpengaruh — tetap least-privilege.
allow if { allow if {
res := input.action.resource res := input.action.resource
res.table.schemaName in {"bronze", "silver", "gold"} res.table.schemaName in write_schemas
some r in effective_roles some r in effective_roles
r == "data-engineer" r == "data-engineer"
} }
+610
View File
@@ -0,0 +1,610 @@
#!/usr/bin/env python3
# =============================================================================
# SiCABE — lib/authz.py
# -----------------------------------------------------------------------------
# Model authorization (config/opa/authz.yml) <-> data ACL OPA (config/opa/data.json).
#
# SUMBER KEBENARAN = authz.yml. data.json ditandai `_generated` dan diperiksa
# lewat `render --check`. Fungsi model murni (pure) agar mudah diuji; hanya
# pembacaan/penulisan berkas yang menyentuh sisi luar.
#
# Pemakaian (biasanya via pembungkus scripts/sicabe-authz):
# python3 lib/authz.py fmt|render|check|list|matrix|show|grant|role|user|catalog
# Env: SICABE_AUTHZ_DRY_RUN=1 -> jangan tulis apa pun, cetak rencana.
# =============================================================================
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path
import yaml
CAPS = ("select", "write", "ddl")
DEPLOY_DIR = Path(__file__).resolve().parent.parent
DEFAULT_MODEL = DEPLOY_DIR / "config" / "opa" / "authz.yml"
DEFAULT_OUT = DEPLOY_DIR / "config" / "opa" / "data.json"
TRINO_CATALOG_DIR = DEPLOY_DIR / "config" / "trino" / "catalog"
DRY = os.environ.get("SICABE_AUTHZ_DRY_RUN") == "1"
HEADER = """\
# =============================================================================
# SiCABE — Model Authorization (SUMBER KEBENARAN) — OPA / Trino
# -----------------------------------------------------------------------------
# Berkas ini SATU-SATUNYA sumber kebenaran ACL. JANGAN mengedit
# config/opa/data.json manual — itu HASIL RENDER:
# ./scripts/sicabe-authz render # authz.yml -> data.json
# ./scripts/sicabe-authz apply # render + reload OPA
# ./scripts/sicabe-authz fmt # rapikan format berkas ini
# Policy Rego membaca data.authz.catalogs / data.authz.write_schemas — nama
# catalog/schema TIDAK di-hardcode di Rego. Ganti nama catalog = ubah `catalogs:`
# di sini -> render -> reload. Dokumentasi: docs/authorization-cli.md
# =============================================================================
"""
class ModelError(Exception):
"""Model authz.yml tidak valid / operasi ditolak."""
# ── Muat & validasi ─────────────────────────────────────────────────────────
def load_model(path: Path) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
except FileNotFoundError:
raise ModelError(f"berkas model tidak ditemukan: {path}")
except yaml.YAMLError as exc:
raise ModelError(f"YAML tidak valid di {path}: {exc}")
def _as_list(value, what: str) -> list:
if not isinstance(value, list):
raise ModelError(f"{what} harus berupa daftar")
return value
def _table_key(table: str) -> str:
parts = str(table).split(".", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ModelError(f"table harus 'schema.nama', dapat: {table!r}")
return f"{parts[0]}_{parts[1]}"
def _grant(grant: dict) -> dict:
if not isinstance(grant, dict) or "table" not in grant:
raise ModelError(f"grant tidak valid: {grant!r}")
caps = _as_list(grant.get("caps", []), f"caps untuk {grant['table']}")
unknown = [c for c in caps if c not in CAPS]
if unknown:
raise ModelError(f"caps tak dikenal {unknown} pada {grant['table']}")
columns = grant.get("columns", "*")
if columns != "*" and not isinstance(columns, list):
raise ModelError(f"columns harus '*' atau daftar, pada {grant['table']}")
return {"table": grant["table"], "caps": list(caps), "columns": columns}
def validate(model: dict) -> None:
if not _as_list(model.get("catalogs", []), "catalogs"):
raise ModelError("catalogs tidak boleh kosong")
_as_list(model.get("schemas", []), "schemas")
roles = model.get("roles")
if not isinstance(roles, dict) or not roles:
raise ModelError("roles harus berupa objek tak kosong")
for name, spec in roles.items():
if spec is not None and not isinstance(spec, dict):
raise ModelError(f"role {name!r} harus objek")
for grant in (spec or {}).get("grants", []):
_grant(grant)
users = model.get("users", {})
if not isinstance(users, dict):
raise ModelError("users harus berupa objek")
for user, role in users.items():
if role not in roles:
raise ModelError(f"user {user!r} menunjuk role tak ada: {role!r}")
# ── Render (untuk OPA) ──────────────────────────────────────────────────────
def _render_role(spec: dict) -> dict:
spec = spec or {}
if spec.get("superuser"):
return {"superuser": True}
tables: dict = {}
for grant in spec.get("grants", []):
key = _table_key(grant["table"])
out: dict = {}
caps = grant.get("caps", [])
for cap in CAPS:
if cap in caps:
out[cap] = True
out["columns"] = grant.get("columns", "*")
tables[key] = out
return {"tables": tables} if tables else {}
def render(model: dict, digest: str) -> dict:
return {
"authz": {"catalogs": list(model.get("catalogs", [])),
"write_schemas": list(model.get("schemas", []))},
"roles": {n: _render_role(s) for n, s in model.get("roles", {}).items()},
"users": dict(model.get("users", {})),
"_generated": {"from": DEFAULT_MODEL.name, "sha256": digest},
}
def digest_of(path: Path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def build(model_path: Path) -> dict:
model = load_model(model_path)
validate(model)
return render(model, digest_of(model_path))
# ── Serialisasi berkas ──────────────────────────────────────────────────────
def dump_json(obj: dict) -> str:
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
def dump_model(model: dict) -> str:
ordered = {k: model[k] for k in ("version", "catalogs", "schemas", "roles", "users") if k in model}
body = yaml.safe_dump(ordered, default_flow_style=None, sort_keys=False,
allow_unicode=True, width=100)
return HEADER + body
def write_atomic(path: Path, text: str) -> None:
path = Path(path)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".authz-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def read_json(path: Path) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
raise ModelError(f"data.json tidak valid: {exc}")
# ── Mutator model (murni: kembalikan model baru) ────────────────────────────
def get_grant(spec: dict, table: str):
for grant in (spec or {}).get("grants", []):
if grant["table"] == table:
return grant
return None
def set_grant(model: dict, role: str, table: str, caps: list, columns, replace: bool) -> str:
roles = model.setdefault("roles", {})
if role not in roles:
raise ModelError(f"role {role!r} belum ada — buat dulu: role add {role}")
_table_key(table)
for c in caps:
if c not in CAPS:
raise ModelError(f"cap tak dikenal: {c} (pilihan: {', '.join(CAPS)})")
spec = roles[role]
if spec is None: # role kosong -> jadikan objek di tempat
spec = {}
roles[role] = spec
if spec.get("superuser"):
raise ModelError(f"role {role!r} superuser — tak perlu grant per-tabel")
grants = spec.setdefault("grants", [])
existing = get_grant(spec, table)
if existing is None:
grants.append({"table": table, "caps": list(dict.fromkeys(caps)),
"columns": columns if columns is not None else "*"})
elif replace:
existing["caps"] = list(dict.fromkeys(caps))
existing["columns"] = columns if columns is not None else existing.get("columns", "*")
else:
existing["caps"] = list(dict.fromkeys(list(existing.get("caps", [])) + list(caps)))
if columns is not None:
existing["columns"] = columns
return ("set" if replace else "set/merge") + f" grant {role} -> {table}"
def remove_grant(model: dict, role: str, table: str, caps: list | None) -> str:
spec = (model.get("roles", {}) or {}).get(role)
if not spec or not spec.get("grants"):
raise ModelError(f"role {role!r} tak punya grant")
existing = get_grant(spec, table)
if existing is None:
raise ModelError(f"grant {role} -> {table} tidak ada")
if not caps:
spec["grants"] = [g for g in spec["grants"] if g["table"] != table]
return f"hapus grant {role} -> {table}"
left = [c for c in existing.get("caps", []) if c not in caps]
if left:
existing["caps"] = left
else:
spec["grants"] = [g for g in spec["grants"] if g["table"] != table]
return f"hapus cap {','.join(caps)} pada {role} -> {table}"
def roles_referencing(model: dict, role: str) -> list:
return [u for u, r in model.get("users", {}).items() if r == role]
# ── Query ───────────────────────────────────────────────────────────────────
def all_tables(model: dict) -> list:
keys = set()
for spec in (model.get("roles", {}) or {}).values():
for grant in (spec or {}).get("grants", []) or []:
keys.add(_table_key(grant["table"]))
return sorted(keys)
def caps_letters(caps: list) -> str:
return "".join(c[0] for c in CAPS if c in caps) or "-"
# ── Perintah ────────────────────────────────────────────────────────────────
def _say(msg: str) -> None:
print(f"[DRY-RUN] {msg}" if DRY else f"{msg}")
def cmd_fmt(model_path: Path) -> int:
model = load_model(model_path)
validate(model)
if DRY:
_say(f"rapikan {model_path}")
return 0
write_atomic(model_path, dump_model(model))
print(f"fmt: {model_path}")
return 0
def cmd_render(model_path: Path, out_path: Path, check: bool) -> int:
expected = build(model_path)
on_disk = read_json(out_path)
if check:
if on_disk == expected:
print(f"SINKRON: {out_path} sesuai {model_path}")
return 0
print(f"TIDAK SINKRON: {out_path} berbeda dari {model_path}", file=sys.stderr)
print(" jalankan: ./scripts/sicabe-authz render", file=sys.stderr)
return 1
if DRY:
_say(f"render {model_path} -> {out_path}")
return 0
write_atomic(out_path, dump_json(expected))
print(f"render: {model_path} -> {out_path}")
return 0
def cmd_list(model_path: Path, what: str) -> int:
model = load_model(model_path)
if what == "catalogs":
for c in model.get("catalogs", []):
print(c)
elif what == "roles":
for name, spec in (model.get("roles", {}) or {}).items():
tag = " (superuser)" if (spec or {}).get("superuser") else ""
print(f"{name}{tag}")
elif what == "users":
for user, role in (model.get("users", {}) or {}).items():
print(f"{user} -> {role}")
elif what == "tables":
for t in all_tables(model):
print(t)
return 0
def cmd_matrix(model_path: Path, schema: str | None) -> int:
model = load_model(model_path)
tables = all_tables(model)
if schema:
tables = [t for t in tables if t.startswith(f"{schema}_")]
roles = list(model.get("roles", {}) or {})
w = max([len(r) for r in roles] + [5])
print(" Tabel:")
for i, t in enumerate(tables):
print(f" [{i}] {t}")
print()
print(" " + " " * (w + 2) + " ".join(f"{i:>3}" for i in range(len(tables))))
for r in roles:
spec = (model.get("roles", {}) or {}).get(r) or {}
if spec.get("superuser"):
print(f" {r:<{w}} " + " ".join("ALL" for _ in tables))
continue
cells = []
for t in tables:
g = get_grant(spec, t.replace("_", ".", 1))
cells.append(f"{caps_letters(g.get('caps', [])):>3}" if g else " .")
print(f" {r:<{w}} " + " ".join(cells))
print("\n Legenda caps: s=select w=write d=ddl | ALL=superuser | .=tak ada grant")
return 0
def cmd_show(model_path: Path, what: str, name: str) -> int:
model = load_model(model_path)
if what == "role":
spec = (model.get("roles", {}) or {}).get(name)
if spec is None:
raise ModelError(f"role {name!r} tidak ada")
if spec.get("superuser"):
print(f"{name}: SUPERUSER (semua operasi)")
for g in spec.get("grants", []) or []:
print(f"{name}: {g['table']} caps=[{','.join(g.get('caps', []))}] columns={g.get('columns')}")
if not spec.get("superuser") and not spec.get("grants"):
print(f"{name}: (tanpa grant)")
elif what == "user":
role = (model.get("users", {}) or {}).get(name)
print(f"{name}: {role}" if role else f"{name}: (bukan service account; role dari grup LDAP)")
elif what == "table":
key = _table_key(name)
found = False
for r, spec in (model.get("roles", {}) or {}).items():
g = get_grant(spec or {}, name)
if g:
found = True
print(f"{name}: {r} caps=[{','.join(g.get('caps', []))}] columns={g.get('columns')}")
elif (spec or {}).get("superuser"):
print(f"{name}: {r} (superuser)")
if not found:
print(f"{name}: (tak ada grant eksplisit; cek superuser/role lain)")
return 0
def _split_csv(value: str | None) -> list:
if not value:
return []
return [v.strip() for v in value.split(",") if v.strip()]
def _columns_arg(value: str | None):
if value is None or value == "*":
return "*"
return _split_csv(value)
def save_model(model_path: Path, model: dict) -> None:
validate(model)
if DRY:
_say(f"tulis {model_path}")
return
write_atomic(model_path, dump_model(model))
def cmd_grant(args, model_path: Path) -> int:
model = load_model(model_path)
if args.action == "add":
msg = set_grant(model, args.role, args.table, _split_csv(args.caps), _columns_arg(args.columns), False)
elif args.action == "set":
msg = set_grant(model, args.role, args.table, _split_csv(args.caps), _columns_arg(args.columns), True)
else:
msg = remove_grant(model, args.role, args.table, _split_csv(getattr(args, "caps", None)) or None)
_say(msg)
save_model(model_path, model)
return 0
def cmd_role(args, model_path: Path) -> int:
model = load_model(model_path)
roles = model.setdefault("roles", {})
if args.action == "add":
if args.role in roles:
raise ModelError(f"role {args.role!r} sudah ada")
roles[args.role] = {"superuser": True} if args.superuser else {}
_say(f"tambah role {args.role}" + (" (superuser)" if args.superuser else ""))
else:
if args.role not in roles:
raise ModelError(f"role {args.role!r} tidak ada")
refs = roles_referencing(model, args.role)
if refs and not args.force:
raise ModelError(f"role {args.role!r} masih dipakai user: {', '.join(refs)} (pakai --force)")
for u in refs:
del model["users"][u]
del roles[args.role]
_say(f"hapus role {args.role}" + (f" (+ unassign {', '.join(refs)})" if refs else ""))
save_model(model_path, model)
return 0
def cmd_user(args, model_path: Path) -> int:
model = load_model(model_path)
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
_say(f"assign {args.user} -> {args.role}")
else:
if args.user not in users:
raise ModelError(f"service account {args.user!r} tidak terdaftar")
del users[args.user]
_say(f"unassign {args.user}")
save_model(model_path, model)
return 0
CATALOG_SCAN_PATTERNS = ("deploy/scripts", "deploy/lib", "deploy/config", "docs")
def _catalog_regexes(old: str, schemas: list) -> list:
"""Pola BERTARGET: hanya rujukan yang benar-benar berarti nama catalog —
bukan `/iceberg/` (prefix REST), `iceberg.catalog.*` (kunci konektor),
`io.trino.plugin.iceberg`, `iceberg-data`, atau prosa."""
o = re.escape(old)
sch = "|".join(re.escape(s) for s in schemas) or "bronze|silver|gold"
return [
re.compile(rf"(?<![\w-]){o}\.(?:{sch})\b"), # iceberg.bronze / iceberg.gold
re.compile(rf"(?<![\w-]){o}\.properties\b"), # iceberg.properties
re.compile(rf"SEED_CATALOG\s*[=(:]\s*{o}\b"), # SEED_CATALOG=iceberg
re.compile(rf"catalog(?:_name)?\s*[:=]\s*{o}\b"), # catalog: iceberg
re.compile(rf"database\s*[:=]\s*{o}\b"), # dbt database: iceberg
re.compile(rf"""["']{o}["']\s*\)"""), # getenv("X", "iceberg")
]
def _scan_catalog_refs(old: str, schemas: list) -> dict:
regexes = _catalog_regexes(old, schemas)
root = DEPLOY_DIR.parent
skip = {DEFAULT_MODEL.resolve(), DEFAULT_OUT.resolve(),
(DEPLOY_DIR / "lib" / "authz.py").resolve(),
(DEPLOY_DIR / "scripts" / "sicabe-authz").resolve()}
exts = {".sh", ".py", ".yml", ".yaml", ".json", ".md", ".properties", ".cfg", ".ini"}
code, docs = [], []
for base in CATALOG_SCAN_PATTERNS:
top = root / base
if not top.exists():
continue
for path in sorted(top.rglob("*")):
if not path.is_file() or path.resolve() in skip or path.suffix not in exts:
continue
if "trino/catalog" in path.as_posix():
continue
bucket = docs if base == "docs" else code
for n, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
if any(rx.search(line) for rx in regexes):
bucket.append(f"{path.relative_to(root)}:{n}: {line.strip()}")
return {"code": code, "docs": docs}
def cmd_catalog(args, model_path: Path) -> int:
model = load_model(model_path)
if args.action == "list":
for c in model.get("catalogs", []):
print(c)
return 0
if args.action == "add":
if args.new in model.get("catalogs", []):
raise ModelError(f"catalog {args.new!r} sudah terdaftar")
model.setdefault("catalogs", []).append(args.new)
_say(f"tambah catalog {args.new}")
save_model(model_path, model)
return 0
if args.action != "rename":
raise ModelError("catalog: aksi harus list|add|rename")
old, new = args.old, args.new
if old not in model.get("catalogs", []):
raise ModelError(f"catalog {old!r} tidak terdaftar di model")
model["catalogs"] = [new if c == old else c for c in model["catalogs"]]
src = TRINO_CATALOG_DIR / f"{old}.properties"
dst = TRINO_CATALOG_DIR / f"{new}.properties"
_say(f"catalog rename {old} -> {new}")
print(f" - model: catalogs: {old} -> {new}")
if src.exists():
print(f" - berkas Trino: {src.relative_to(DEPLOY_DIR.parent)} -> {dst.name}")
if not DRY:
dst.write_text(src.read_text())
src.unlink()
else:
print(f" - berkas Trino {src.name} tidak ditemukan (dilewati)")
save_model(model_path, model)
if not DRY:
cmd_render(model_path, DEFAULT_OUT, False)
print(" - referensi KODE/KONFIGURASI (perlu ditinjau, TIDAK diubah otomatis):")
hits = _scan_catalog_refs(old, model.get("schemas", []))
for h in hits["code"] or ["(tidak ada)"]:
print(f" {h}")
if hits["docs"]:
print(f" - referensi DOKUMENTASI ({len(hits['docs'])} baris, perlu diperbarui):")
print(" (jalankan: grep -rn '" + old + "\\.' docs/ )")
return 0
# ── Argparse ────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="sicabe-authz", description="Kelola authorization OPA (authz.yml -> data.json)")
p.add_argument("--model", type=Path, default=DEFAULT_MODEL)
p.add_argument("--out", type=Path, default=DEFAULT_OUT)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("fmt")
sub.add_parser("render")
sub.add_parser("check")
pl = sub.add_parser("list")
pl.add_argument("what", choices=["catalogs", "roles", "users", "tables"])
pm = sub.add_parser("matrix")
pm.add_argument("--schema")
ps = sub.add_parser("show")
ps.add_argument("what", choices=["role", "user", "table"])
ps.add_argument("name")
pg = sub.add_parser("grant")
pg.add_argument("action", choices=["add", "set", "remove"])
pg.add_argument("role")
pg.add_argument("table")
pg.add_argument("--caps")
pg.add_argument("--columns")
pr = sub.add_parser("role")
pr.add_argument("action", choices=["add", "remove"])
pr.add_argument("role")
pr.add_argument("--superuser", action="store_true")
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("role", nargs="?")
pc = sub.add_parser("catalog")
pc.add_argument("action", choices=["list", "add", "rename"])
pc.add_argument("old", nargs="?")
pc.add_argument("new", nargs="?")
return p
def dispatch(args) -> int:
if args.cmd == "fmt":
return cmd_fmt(args.model)
if args.cmd == "render":
return cmd_render(args.model, args.out, False)
if args.cmd == "check":
return cmd_render(args.model, args.out, True)
if args.cmd == "list":
return cmd_list(args.model, args.what)
if args.cmd == "matrix":
return cmd_matrix(args.model, args.schema)
if args.cmd == "show":
return cmd_show(args.model, args.what, args.name)
if args.cmd == "grant":
return cmd_grant(args, args.model)
if args.cmd == "role":
return cmd_role(args, args.model)
if args.cmd == "user":
return cmd_user(args, args.model)
if args.cmd == "catalog":
return cmd_catalog(args, args.model)
raise ModelError(f"perintah tak dikenal: {args.cmd}")
def main(argv: list | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return dispatch(args)
except ModelError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# =============================================================================
# eBdesk SiCABE — sicabe-authz
# -----------------------------------------------------------------------------
# CLI pengelolaan AUTHORIZATION (OPA/Trino) berbasis model config/opa/authz.yml.
#
# 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.
# =============================================================================
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
OPA_CONTAINER="${OPA_CONTAINER_NAME:-sicabe-opa}"
usage() {
sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# ── Ambil --dry-run dari posisi mana pun ─────────────────────────────────────
ARGS=()
for a in "$@"; do
if [[ "${a}" == "--dry-run" ]]; then
export SICABE_AUTHZ_DRY_RUN=1
else
ARGS+=("${a}")
fi
done
set -- ${ARGS[@]+"${ARGS[@]}"}
[[ $# -eq 0 || "${1}" == "-h" || "${1}" == "--help" || "${1}" == "help" ]] && { usage; exit 0; }
PY="${SCRIPT_DIR}/lib/authz.py"
# ── reload: recreate container OPA agar memuat policy+data terbaru ───────────
reload_opa() {
if ! command -v docker >/dev/null 2>&1; then
echo -e "${WARN} docker tak tersedia — lewati reload OPA."
return 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "${OPA_CONTAINER}"; then
echo -e "${WARN} container ${OPA_CONTAINER} tidak berjalan di host ini."
echo -e "${INFO} Jalankan 'sicabe-authz reload' DI HOST OPA, atau sinkronkan dulu (--sync)."
return 0
fi
echo -e "${INFO} recreate container ${OPA_CONTAINER} (muat ulang policy + data)..."
( cd "${SCRIPT_DIR}" && docker compose --profile all up -d --force-recreate opa >/dev/null )
echo -e "${OK} OPA dimuat ulang."
}
case "${1}" in
apply)
shift
NO_RELOAD=0
for a in "$@"; do [[ "${a}" == "--no-reload" ]] && NO_RELOAD=1; done
python3 "${PY}" render
[[ "${NO_RELOAD}" == "1" ]] || reload_opa
;;
reload)
reload_opa
;;
*)
python3 "${PY}" "$@"
;;
esac
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# =============================================================================
# test-authz.sh — uji model, CLI, dan kebijakan OPA (authorization)
# -----------------------------------------------------------------------------
# Yang dibuktikan (bukan sekadar "berkasnya ada"):
# 1. data.json SINKRON dengan authz.yml (hasil render), & render round-trip.
# 2. Policy Rego TIDAK lagi meng-hardcode nama catalog/schema (data-driven).
# 3. Mutasi CLI (role/grant/user) pada salinan model bekerja & bolak-balik bersih.
# 4. `--dry-run` benar-benar tidak menulis.
# 5. Kebijakan OPA (dievaluasi dengan OPA asli via docker) mempertahankan
# perilaku lama DAN mengganti-ganti catalog lewat data (uji rename).
# Bagian ini DILEWATI bila docker tak tersedia.
#
# Jalankan: ./scripts/test-authz.sh
# =============================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CLI="${SCRIPT_DIR}/sicabe-authz"
MODEL="${DEPLOY_DIR}/config/opa/authz.yml"
DATA="${DEPLOY_DIR}/config/opa/data.json"
REGO="${DEPLOY_DIR}/config/opa/policies/trino.rego"
OUT="$(mktemp -d)"
trap 'rm -rf "${OUT}"' EXIT
GRN=$'\e[32m'; RED=$'\e[31m'; BLD=$'\e[1m'; NC=$'\e[0m'
PASS_N=0; FAIL_N=0; SKIP_N=0
ok() { printf " ${GRN}✅${NC} %s\n" "$1"; PASS_N=$((PASS_N + 1)); }
bad() { printf " ${RED}❌${NC} %s\n" "$1"; FAIL_N=$((FAIL_N + 1)); }
skip() { printf " ${BLD}…${NC} %s\n" "$1"; SKIP_N=$((SKIP_N + 1)); }
echo -e "${BLD}Uji authorization (model authz.yml + CLI + policy OPA)${NC}"
echo
# ─── A. Model & render ───────────────────────────────────────────────────────
echo -e "${BLD}A. Model & render${NC}"
[[ -f "${MODEL}" ]] && ok "authz.yml ada" || bad "authz.yml tidak ada"
if python3 "${DEPLOY_DIR}/lib/authz.py" --out "${OUT}/data.json" render >/dev/null 2>&1; then
ok "render authz.yml -> data.json berhasil"
else
bad "render gagal (model tidak valid?)"
fi
if "${CLI}" check >/dev/null 2>&1; then
ok "data.json SINKRON dengan authz.yml"
else
bad "data.json TIDAK sinkron (jalankan: sicabe-authz render)"
fi
if python3 - "${DATA}" "${OUT}/data.json" <<'PY'
import json, sys
a = json.load(open(sys.argv[1])); b = json.load(open(sys.argv[2]))
sys.exit(0 if a == b else 1)
PY
then ok "data.json identik dengan hasil render"; else bad "data.json berbeda dari hasil render"; fi
# ─── B. Policy data-driven ───────────────────────────────────────────────────
echo; echo -e "${BLD}B. Policy Rego data-driven${NC}"
if grep -q 'data.authz.catalogs' "${REGO}" && grep -q 'data.authz.write_schemas' "${REGO}"; then
ok "Rego membaca katalog/schema dari data.authz.*"
else
bad "Rego belum data-driven (data.authz.* tidak ditemukan)"
fi
if grep -qE '"iceberg"' "${REGO}"; then
bad "Rego masih meng-hardcode literal \"iceberg\""
else
ok "Rego bebas literal katalog \"iceberg\""
fi
# ─── C. Mutasi CLI pada salinan model ────────────────────────────────────────
echo; echo -e "${BLD}C. Mutasi CLI (salinan model)${NC}"
M="${OUT}/authz.yml"; cp "${MODEL}" "${M}"
cli() { "${CLI}" --model "${M}" "$@"; }
roles_out="$("${CLI}" list roles)"
case "${roles_out}" in *data-engineer*) ok "list roles memuat data-engineer" ;; *) bad "list roles keliru" ;; esac
"${CLI}" matrix --schema silver >/dev/null 2>&1 && ok "matrix --schema berjalan" || bad "matrix gagal"
"${CLI}" --model "${M}" role add data-scientist >/dev/null 2>&1
"${CLI}" --model "${M}" grant add data-scientist gold.orders_gold --caps select --columns id,total_value >/dev/null 2>&1
out="$(cli show role data-scientist)"
case "${out}" in *"gold.orders_gold"*"caps=[select]"*) ok "grant add tersimpan (select, kolom)" ;; *) bad "grant add tidak tersimpan: ${out}" ;; esac
"${CLI}" --model "${M}" grant set data-scientist gold.orders_gold --caps select,write --columns "*" >/dev/null 2>&1
out="$(cli show role data-scientist)"
case "${out}" in *"caps=[select,write]"*) ok "grant set menimpa caps" ;; *) bad "grant set gagal: ${out}" ;; esac
"${CLI}" --model "${M}" grant remove data-scientist gold.orders_gold --caps write >/dev/null 2>&1
out="$(cli show role data-scientist)"
case "${out}" in *"caps=[select]"*) ok "grant remove --caps menyisakan select" ;; *) bad "grant remove gagal: ${out}" ;; esac
"${CLI}" --model "${M}" user assign svc_ml data-scientist >/dev/null 2>&1
out="$(cli show user svc_ml)"
case "${out}" in *"svc_ml: data-scientist"*) ok "user assign bekerja" ;; *) bad "user assign gagal: ${out}" ;; esac
"${CLI}" --model "${M}" user unassign svc_ml >/dev/null 2>&1
"${CLI}" --model "${M}" role remove data-scientist >/dev/null 2>&1
python3 -c "import yaml,sys; sys.exit(0 if 'data-scientist' not in yaml.safe_load(open('${M}'))['roles'] else 1)" \
&& ok "role remove membersihkan model" || bad "role remove gagal"
# error & dry-run
if "${CLI}" --model "${M}" grant add data-nope bronze.orders --caps select >/dev/null 2>&1; then
bad "grant ke role tak ada seharusnya GAGAL"
else
ok "grant ke role tak ada ditolak"
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}"
if docker info >/dev/null 2>&1; then
if python3 - "${DEPLOY_DIR}" <<'PY'
import json, os, shutil, subprocess, sys, tempfile
from pathlib import Path
dep = Path(sys.argv[1]); opa = dep / "config" / "opa"; IMG = "openpolicyagent/opa:1.20.2"
def build(cases=None, variant=None):
ctx = Path(tempfile.mkdtemp())
shutil.copytree(opa / "policies", ctx / "policies")
data = json.loads((opa / "data.json").read_text())
if variant:
data.update(variant)
(ctx / "data.json").write_text(json.dumps(data))
dockerfile = f"FROM {IMG}\nCOPY policies /cfg/policies\nCOPY data.json /cfg/data.json\n"
if cases:
(ctx / "cases").mkdir()
for i, c in enumerate(cases):
(ctx / "cases" / f"{i}.json").write_text(json.dumps(c[0]))
dockerfile += "COPY cases /cfg/cases\n"
(ctx / "Dockerfile").write_text(dockerfile)
tag = f"sicabe-authz-test:{os.path.basename(ctx)}"
subprocess.run(["docker", "build", "-t", tag, str(ctx)], check=True, capture_output=True)
return tag
def ev(tag, i=None):
cmd = ["docker", "run", "--rm", "--user", "0", tag, "eval",
"-d", "/cfg/policies", "-d", "/cfg/data.json", "--format", "raw"]
if i is not None:
cmd += ["-i", f"/cfg/cases/{i}.json"]
cmd.append("data.trino.allow")
return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
def tin(op, cat, sch, tbl, cols, user, groups):
return {"action": {"operation": op, "resource": {"table": {"catalogName": cat, "schemaName": sch, "tableName": tbl, "columns": cols}}},
"context": {"identity": {"user": user, "groups": groups}}}
base_cases = [
({}, "false"), # deny-by-default
(tin("DROPTABLE", "iceberg", "gold", "orders_gold", [], "admin", ["data-admin"]), "true"),
(tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["id", "total_value"], "u", ["data-analyst"]), "true"),
(tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["id", "order_date"], "u", ["data-analyst"]), "false"),
]
ren_cases = [
(tin("SELECTFROMCOLUMNS", "lakehouse", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "true"),
(tin("SELECTFROMCOLUMNS", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "false"),
]
fails = 0
tb = build(base_cases)
if subprocess.run(["docker", "run", "--rm", "--user", "0", tb, "check", "/cfg/policies"],
capture_output=True).returncode != 0:
print(" opa check GAGAL"); fails += 1
for i, (_, want) in enumerate(base_cases):
got = ev(tb, i)
print(f" keputusan[{i}] -> {got or '(undefined)'} (harap {want})")
if got != want:
fails += 1
tr = build(ren_cases, {"authz": {"catalogs": ["lakehouse"], "write_schemas": ["bronze", "silver", "gold"]}})
for i, (_, want) in enumerate(ren_cases):
got = ev(tr, i)
print(f" rename[{i}] -> {got or '(undefined)'} (harap {want})")
if got != want:
fails += 1
sys.exit(1 if fails else 0)
PY
then ok "policy OPA: perilaku dasar + ganti catalog lewat data (via docker)"
else bad "policy OPA: ada keputusan yang tidak sesuai"
fi
else
skip "docker tak tersedia — lewati evaluasi policy OPA"
fi
# ─── Ringkasan ───────────────────────────────────────────────────────────────
echo
echo " skor: ${PASS_N} lulus · ${FAIL_N} gagal · ${SKIP_N} dilewati"
if [[ ${FAIL_N} -eq 0 ]]; then
echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} cek)"
exit 0
fi
echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} masalah · ${PASS_N} lulus"
exit 1