From f534e8da5464ff14598d13dbc6bb128e219c3f89 Mon Sep 17 00:00:00 2001 From: Subroto Date: Tue, 13 Oct 2026 14:44:55 +0700 Subject: [PATCH] feat sicabe-authz --- deploy/config/opa/authz.yml | 58 +++ deploy/config/opa/data.json | 80 +++- deploy/config/opa/policies/trino.rego | 28 +- deploy/lib/authz.py | 610 ++++++++++++++++++++++++++ deploy/scripts/sicabe-authz | 83 ++++ deploy/scripts/test-authz.sh | 188 ++++++++ 6 files changed, 1030 insertions(+), 17 deletions(-) create mode 100644 deploy/config/opa/authz.yml create mode 100644 deploy/lib/authz.py create mode 100755 deploy/scripts/sicabe-authz create mode 100755 deploy/scripts/test-authz.sh diff --git a/deploy/config/opa/authz.yml b/deploy/config/opa/authz.yml new file mode 100644 index 0000000..a476be2 --- /dev/null +++ b/deploy/config/opa/authz.yml @@ -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} diff --git a/deploy/config/opa/data.json b/deploy/config/opa/data.json index c3c95da..5453b16 100644 --- a/deploy/config/opa/data.json +++ b/deploy/config/opa/data.json @@ -1,29 +1,83 @@ { + "authz": { + "catalogs": [ + "iceberg" + ], + "write_schemas": [ + "bronze", + "silver", + "gold" + ] + }, "roles": { "data-admin": { "superuser": true }, "data-engineer": { "tables": { - "bronze_orders": { "select": true, "write": true, "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": "*" } + "bronze_orders": { + "select": true, + "write": true, + "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": { "tables": { - "bronze_orders": { "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": "*" } + "bronze_orders": { + "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": { "tables": { - "gold_orders_gold": { "select": true, "columns": "*" }, - "silver_orders_silver": { "select": true, "columns": ["id", "total_value"] } + "gold_orders_gold": { + "select": true, + "columns": "*" + }, + "silver_orders_silver": { + "select": true, + "columns": [ + "id", + "total_value" + ] + } } }, "data-viewer": {} @@ -33,5 +87,9 @@ "svc_airflow": "data-engineer", "svc_seed": "data-engineer", "svc_om": "data-catalog" + }, + "_generated": { + "from": "authz.yml", + "sha256": "8c7de053a5683e00c27f8d92157463c65ff6d45d5802e1e03b48bb9a2a933cb5" } } diff --git a/deploy/config/opa/policies/trino.rego b/deploy/config/opa/policies/trino.rego index effeee4..0cdfe35 100644 --- a/deploy/config/opa/policies/trino.rego +++ b/deploy/config/opa/policies/trino.rego @@ -10,6 +10,9 @@ # # Grup user berasal dari LDAP group provider Trino (OpenLDAP, cn = data-*). # Izin didefinisikan sebagai data di data.json → data.roles..tables.. +# 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: "*" | [..] } # - data-admin : superuser (semua operasi) # - 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_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 # yang tidak terdaftar di LDAP (mis. svc_dbt, svc_om dari password file Basic). effective_roles contains r if { @@ -105,12 +121,12 @@ allow if { allow if { op in {"ACCESSCATALOG", "FILTERCATALOGS"} res := input.action.resource - res.catalog.name in {"iceberg", "system"} + res.catalog.name in allowed_catalogs } allow if { op == "FILTERSCHEMAS" res := input.action.resource - res.schema.catalogName in {"iceberg", "system"} + res.schema.catalogName in allowed_catalogs } allow if { op == "FILTERTABLES" @@ -163,7 +179,7 @@ cols_all_ok(columns, cols) if { allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource - res.table.catalogName == "iceberg" + res.table.catalogName in data_catalogs tid := table_id(res.table.schemaName, res.table.tableName) cols := res.table.columns count(cols) > 0 @@ -180,7 +196,7 @@ allow if { allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource - res.table.catalogName == "iceberg" + res.table.catalogName in data_catalogs tid := table_id(res.table.schemaName, res.table.tableName) count(res.table.columns) == 0 some r in effective_roles @@ -234,7 +250,7 @@ any_cap(grant) if { allow if { op == "FILTERTABLES" res := input.action.resource - res.table.catalogName == "iceberg" + res.table.catalogName in data_catalogs tid := table_id(res.table.schemaName, res.table.tableName) some r in effective_roles grant := data.roles[r].tables[tid] @@ -264,7 +280,7 @@ allow if { # eksplisit. Layer lain (silver/gold) TIDAK terpengaruh — tetap least-privilege. allow if { res := input.action.resource - res.table.schemaName in {"bronze", "silver", "gold"} + res.table.schemaName in write_schemas some r in effective_roles r == "data-engineer" } diff --git a/deploy/lib/authz.py b/deploy/lib/authz.py new file mode 100644 index 0000000..8d0285d --- /dev/null +++ b/deploy/lib/authz.py @@ -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"(? 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()) diff --git a/deploy/scripts/sicabe-authz b/deploy/scripts/sicabe-authz new file mode 100755 index 0000000..7dfbbc4 --- /dev/null +++ b/deploy/scripts/sicabe-authz @@ -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 diff --git a/deploy/scripts/test-authz.sh b/deploy/scripts/test-authz.sh new file mode 100755 index 0000000..17c1b81 --- /dev/null +++ b/deploy/scripts/test-authz.sh @@ -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