From 678885f681c58654f6c6e4f4d553497f195f63e8 Mon Sep 17 00:00:00 2001 From: Subroto Date: Tue, 13 Oct 2026 18:28:09 +0700 Subject: [PATCH] sync a58faaa refactor(authz) --- deploy/config/opa/authz.yml | 17 +++- deploy/config/opa/data.json | 22 ++++- deploy/config/opa/policies/trino.rego | 106 ++++++++++++++++---- deploy/lib/authz.py | 134 +++++++++++++++++++++++--- deploy/scripts/sicabe-authz | 1 + deploy/scripts/test-authz.sh | 50 +++++++++- 6 files changed, 290 insertions(+), 40 deletions(-) diff --git a/deploy/config/opa/authz.yml b/deploy/config/opa/authz.yml index a476be2..1544c9e 100644 --- a/deploy/config/opa/authz.yml +++ b/deploy/config/opa/authz.yml @@ -6,9 +6,10 @@ # ./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 +# Policy Rego membaca data.authz.catalogs + grant schema (data.roles..schema_grants) +# — nama catalog/schema & cakupan GRANT TIDAK di-hardcode di Rego. Ganti nama +# catalog = ubah `catalogs:` di sini -> render -> reload. Grant tingkat-schema: +# `roles..schemas: [{schema, caps, columns}]`. Dokumentasi: docs/authorization-cli.md # ============================================================================= version: 1 catalogs: [iceberg] @@ -29,6 +30,16 @@ roles: - table: gold.orders_gold caps: [select, write, ddl] columns: '*' + schemas: + - schema: bronze + caps: [select, write, ddl] + columns: '*' + - schema: silver + caps: [select, write, ddl] + columns: '*' + - schema: gold + caps: [select, write, ddl] + columns: '*' data-catalog: grants: - table: bronze.orders diff --git a/deploy/config/opa/data.json b/deploy/config/opa/data.json index 5453b16..7b2bc1f 100644 --- a/deploy/config/opa/data.json +++ b/deploy/config/opa/data.json @@ -39,6 +39,26 @@ "ddl": true, "columns": "*" } + }, + "schema_grants": { + "bronze": { + "select": true, + "write": true, + "ddl": true, + "columns": "*" + }, + "silver": { + "select": true, + "write": true, + "ddl": true, + "columns": "*" + }, + "gold": { + "select": true, + "write": true, + "ddl": true, + "columns": "*" + } } }, "data-catalog": { @@ -90,6 +110,6 @@ }, "_generated": { "from": "authz.yml", - "sha256": "8c7de053a5683e00c27f8d92157463c65ff6d45d5802e1e03b48bb9a2a933cb5" + "sha256": "36620fa9962357759a498f40d22a3a729d8fa7a8fa69d9941dbd270806968aad" } } diff --git a/deploy/config/opa/policies/trino.rego b/deploy/config/opa/policies/trino.rego index 0cdfe35..7c5003d 100644 --- a/deploy/config/opa/policies/trino.rego +++ b/deploy/config/opa/policies/trino.rego @@ -38,9 +38,7 @@ identity_user := input.context.identity.user # 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 } @@ -157,6 +155,15 @@ base_table_name(name) := name if { table_id(schema, table) := sprintf("%s_%s", [schema, base_table_name(table)]) +# ── Grant tingkat-SCHEMA: berlaku untuk SEMUA tabel di schema tersebut ─────── +# Bentuk data (dari authz.yml `roles..schemas`): +# data.roles..schema_grants. = { select/write/ddl, columns } +# Digabung (union) dengan grant per-tabel. Tabel baru (termasuk sementara dbt/Sling +# milik engineer) otomatis ikut ter-cover bila grant-nya di level schema. +schema_grant(role, schema) := sg if { + sg := data.roles[role].schema_grants[schema] +} + allow if { cap := op_caps[op] res := input.action.resource @@ -257,30 +264,89 @@ allow if { any_cap(grant) } +# ── Grant tingkat-SCHEMA (union dgn grant per-tabel di atas) ───────────────── +# Operasi per-tabel (non-SELECT) yang dicakup cap. +allow if { + cap := op_caps[op] + res := input.action.resource + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + sg[cap] == true +} + +# SELECT per-kolom pada tabel di schema yang di-grant. +allow if { + op == "SELECTFROMCOLUMNS" + res := input.action.resource + res.table.catalogName in data_catalogs + cols := res.table.columns + count(cols) > 0 + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + sg.select == true + cols_all_ok(sg.columns, cols) +} + +# SELECT tanpa kolom eksplisit (count(*)) pada schema yang di-grant. +allow if { + op == "SELECTFROMCOLUMNS" + res := input.action.resource + res.table.catalogName in data_catalogs + count(res.table.columns) == 0 + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + sg.select == true +} + +# Listing tabel: terlihat bila schema punya grant apapun. +allow if { + op == "FILTERTABLES" + res := input.action.resource + res.table.catalogName in data_catalogs + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + any_cap(sg) +} + +# FilterColumns (visibilitas kolom) utk schema yang di-grant. +allow if { + op == "FILTERCOLUMNS" + res := input.action.resource + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + sg.select == true + sg.columns == "*" +} +allow if { + op == "FILTERCOLUMNS" + res := input.action.resource + cols := res.table.columns + count(cols) > 0 + some r in effective_roles + sg := schema_grant(r, res.table.schemaName) + sg.select == true + cols_all_ok(sg.columns, cols) +} + allow if { op == "SetSystemSessionProperty" } -# ── DEV/EVAL: data-engineer bebas DDL + tulis di schema bronze ─────────────── -# Diminta eksplisit (2026-09-22) untuk mempermudah ingestion (Sling) dan -# transform (dbt) di layer bronze. +# ── Blok luas: role dengan schema-grant 'write' = OPERASI APA PUN di schema ── +# Dulu HARDCODE (role data-engineer + schema bronze/silver/gold via write_schemas). +# Kini DATA-DRIVEN dari authz.yml `roles..schemas` — eksplisit & terport. # -# ⚠️ Cakupan blok ini SENGAJA LEBIH LUAS dari least-privilege per-tabel: -# 1. Berlaku untuk OPERASI APA PUN yang menargetkan tabel di schema bronze — -# termasuk operasi level-kolom yang dikirim Trino untuk UPDATE/MERGE, yang -# tidak punya entri di op_caps sehingga sebelumnya jatuh ke deny-by-default. -# Tanpa ini, `MERGE` (upsert) selalu gagal: "Access Denied: Cannot update -# columns [...]". -# 2. Berlaku untuk tabel apa pun, termasuk tabel sementara `_tmp` milik -# Sling. Tabel itu tidak akan cocok dengan grant biasa karena policy hanya -# men-strip sufiks milik dbt (`__dbt_tmp`, `__dbt_backup`). -# -# Efek: tabel baru di bronze tidak lagi perlu baris grant di data.json. -# Mempersempit kembali: hapus blok ini, atau tambahkan syarat daftar nama tabel -# eksplisit. Layer lain (silver/gold) TIDAK terpengaruh — tetap least-privilege. +# Cakupan sengaja LEBIH LUAS dari least-privilege per-tabel, karena: +# 1. Menangkap OPERASI APA PUN di schema itu — termasuk operasi level-kolom yang +# dikirim Trino untuk UPDATE/MERGE dan TIDAK punya entri di op_caps. Tanpa ini +# `MERGE` (upsert) gagal: "Access Denied: Cannot update columns [...]". +# 2. Berlaku untuk tabel apa pun di schema itu, termasuk tabel sementara +# `_tmp` (Sling) dan tabel baru → tak perlu grant per-tabel. +# Hanya bila schema-grant punya 'write' — role 'select'-only (mis. data-analyst) +# TIDAK terpengaruh (tetap least-privilege). allow if { res := input.action.resource - res.table.schemaName in write_schemas some r in effective_roles - r == "data-engineer" + sg := schema_grant(r, res.table.schemaName) + sg.write == true } diff --git a/deploy/lib/authz.py b/deploy/lib/authz.py index 97d95e7..8c0e90a 100644 --- a/deploy/lib/authz.py +++ b/deploy/lib/authz.py @@ -43,9 +43,10 @@ HEADER = """\ # ./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 +# Policy Rego membaca data.authz.catalogs + grant schema (data.roles..schema_grants) +# — nama catalog/schema & cakupan GRANT TIDAK di-hardcode di Rego. Ganti nama +# catalog = ubah `catalogs:` di sini -> render -> reload. Grant tingkat-schema: +# `roles..schemas: [{schema, caps, columns}]`. Dokumentasi: docs/authorization-cli.md # ============================================================================= """ @@ -92,6 +93,22 @@ def _grant(grant: dict) -> dict: return {"table": grant["table"], "caps": list(caps), "columns": columns} +def _schema_grant(sg: dict) -> dict: + if not isinstance(sg, dict) or "schema" not in sg: + raise ModelError(f"schema grant tidak valid: {sg!r}") + schema = str(sg["schema"]) + if not schema or "." in schema: + raise ModelError(f"schema grant harus nama schema (tanpa '.'): {sg['schema']!r}") + caps = _as_list(sg.get("caps", []), f"caps untuk schema {schema}") + unknown = [c for c in caps if c not in CAPS] + if unknown: + raise ModelError(f"caps tak dikenal {unknown} pada schema {schema}") + columns = sg.get("columns", "*") + if columns != "*" and not isinstance(columns, list): + raise ModelError(f"columns harus '*' atau daftar, pada schema {schema}") + return {"schema": schema, "caps": list(caps), "columns": columns} + + def validate(model: dict) -> None: if not _as_list(model.get("catalogs", []), "catalogs"): raise ModelError("catalogs tidak boleh kosong") @@ -104,6 +121,8 @@ def validate(model: dict) -> None: raise ModelError(f"role {name!r} harus objek") for grant in (spec or {}).get("grants", []): _grant(grant) + for sg in (spec or {}).get("schemas", []) or []: + _schema_grant(sg) users = model.get("users", {}) if not isinstance(users, dict): raise ModelError("users harus berupa objek") @@ -118,17 +137,29 @@ def _render_role(spec: dict) -> dict: spec = spec or {} if spec.get("superuser"): return {"superuser": True} + out: dict = {} tables: dict = {} for grant in spec.get("grants", []): - key = _table_key(grant["table"]) - out: dict = {} + g: 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 {} + g[cap] = True + g["columns"] = grant.get("columns", "*") + tables[_table_key(grant["table"])] = g + if tables: + out["tables"] = tables + schemas: dict = {} + for sg in spec.get("schemas", []) or []: + g = {} + for cap in CAPS: + if cap in sg.get("caps", []): + g[cap] = True + g["columns"] = sg.get("columns", "*") + schemas[sg["schema"]] = g + if schemas: + out["schema_grants"] = schemas + return out def render(model: dict, digest: str) -> dict: @@ -243,6 +274,66 @@ def remove_grant(model: dict, role: str, table: str, caps: list | None) -> str: return f"hapus cap {','.join(caps)} pada {role} -> {table}" +def get_schema_grant(spec: dict, schema: str): + for sg in (spec or {}).get("schemas", []) or []: + if sg["schema"] == schema: + return sg + return None + + +def _spec_for(model: dict, role: str) -> dict: + roles = model.setdefault("roles", {}) + if role not in roles: + raise ModelError(f"role {role!r} belum ada — buat dulu: role add {role}") + spec = roles[role] + if spec is None: + spec = {} + roles[role] = spec + if spec.get("superuser"): + raise ModelError(f"role {role!r} superuser — tak perlu grant") + return spec + + +def set_schema_grant(model: dict, role: str, schema: str, caps: list, columns, replace: bool) -> str: + if not schema or "." in schema: + raise ModelError(f"schema harus nama schema (tanpa '.'): {schema!r}") + for c in caps: + if c not in CAPS: + raise ModelError(f"cap tak dikenal: {c} (pilihan: {', '.join(CAPS)})") + spec = _spec_for(model, role) + sgs = spec.setdefault("schemas", []) + existing = get_schema_grant(spec, schema) + if existing is None: + sgs.append({"schema": schema, "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} -> {schema}.*" + + +def remove_schema_grant(model: dict, role: str, schema: str, caps: list | None) -> str: + spec = (model.get("roles", {}) or {}).get(role) + if not spec or not spec.get("schemas"): + raise ModelError(f"role {role!r} tak punya schema grant") + existing = get_schema_grant(spec, schema) + if existing is None: + raise ModelError(f"schema grant {role} -> {schema}.* tidak ada") + if not caps: + spec["schemas"] = [sg for sg in spec["schemas"] if sg["schema"] != schema] + return f"hapus schema grant {role} -> {schema}.*" + left = [c for c in existing.get("caps", []) if c not in caps] + if left: + existing["caps"] = left + else: + spec["schemas"] = [sg for sg in spec["schemas"] if sg["schema"] != schema] + return f"hapus cap {','.join(caps)} pada {role} -> {schema}.*" + + def roles_referencing(model: dict, role: str) -> list: return [u for u, r in model.get("users", {}).items() if r == role] @@ -337,6 +428,14 @@ def cmd_matrix(model_path: Path, schema: str | None) -> int: 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") + schema_lines = [] + for r in roles: + spec = (model.get("roles", {}) or {}).get(r) or {} + for sg in spec.get("schemas", []) or []: + schema_lines.append(f" {r:<{w}} {sg['schema']}.* [{caps_letters(sg.get('caps', []))}]") + if schema_lines: + print("\n Grant tingkat-SCHEMA (berlaku untuk SEMUA tabel di schema):") + print("\n".join(schema_lines)) return 0 @@ -350,7 +449,9 @@ def cmd_show(model_path: Path, what: str, name: str) -> int: 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"): + for sg in spec.get("schemas", []) or []: + print(f"{name}: {sg['schema']}.* caps=[{','.join(sg.get('caps', []))}] columns={sg.get('columns')} (seluruh schema)") + if not spec.get("superuser") and not spec.get("grants") and not spec.get("schemas"): print(f"{name}: (tanpa grant)") elif what == "user": role = (model.get("users", {}) or {}).get(name) @@ -392,12 +493,19 @@ def save_model(model_path: Path, model: dict) -> None: def cmd_grant(args, model_path: Path) -> int: model = load_model(model_path) + is_schema = args.table.endswith(".*") + target = args.table[:-2] if is_schema else args.table + caps = _split_csv(args.caps) + columns = _columns_arg(args.columns) if args.action == "add": - msg = set_grant(model, args.role, args.table, _split_csv(args.caps), _columns_arg(args.columns), False) + fn = set_schema_grant if is_schema else set_grant + msg = fn(model, args.role, target, caps, columns, False) elif args.action == "set": - msg = set_grant(model, args.role, args.table, _split_csv(args.caps), _columns_arg(args.columns), True) + fn = set_schema_grant if is_schema else set_grant + msg = fn(model, args.role, target, caps, columns, True) else: - msg = remove_grant(model, args.role, args.table, _split_csv(getattr(args, "caps", None)) or None) + fn = remove_schema_grant if is_schema else remove_grant + msg = fn(model, args.role, target, caps or None) _say(msg) save_model(model_path, model) return 0 diff --git a/deploy/scripts/sicabe-authz b/deploy/scripts/sicabe-authz index f389cea..df94d41 100755 --- a/deploy/scripts/sicabe-authz +++ b/deploy/scripts/sicabe-authz @@ -29,6 +29,7 @@ MODEL OPA (authz.yml -> data.json): matrix [--schema ] matriks role x tabel (s=select w=write d=ddl) show role|user|table detail satu objek grant add|set|remove [--caps a,b] [--columns x,y|*] + grant add '.*' [--caps a,b] # seluruh tabel di schema (mis. gold.*) role add|remove [--superuser] [--force] user assign|unassign service account -> role (model) catalog list|add|rename kelola catalog (rename ikut berkas Trino) diff --git a/deploy/scripts/test-authz.sh b/deploy/scripts/test-authz.sh index 54e93f2..3de1451 100755 --- a/deploy/scripts/test-authz.sh +++ b/deploy/scripts/test-authz.sh @@ -55,10 +55,10 @@ then ok "data.json identik dengan hasil render"; else bad "data.json berbeda dar # ─── 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.*" +if grep -q 'data.authz.catalogs' "${REGO}" && grep -q 'schema_grant' "${REGO}"; then + ok "Rego data-driven (data.authz.catalogs + schema_grant)" else - bad "Rego belum data-driven (data.authz.* tidak ditemukan)" + bad "Rego belum data-driven (data.authz.* / schema_grant tidak ditemukan)" fi if grep -qE '"iceberg"' "${REGO}"; then bad "Rego masih meng-hardcode literal \"iceberg\"" @@ -86,6 +86,19 @@ case "${out}" in *"caps=[select,write]"*) ok "grant set menimpa caps" ;; *) bad 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}" grant add data-scientist 'gold.*' --caps select --columns "*" >/dev/null 2>&1 +out="$(cli show role data-scientist)" +case "${out}" in *"gold.*"*) ok "grant tingkat-schema tersimpan (gold.*)" ;; *) bad "grant schema tak tersimpan: ${out}" ;; esac +python3 "${DEPLOY_DIR}/lib/authz.py" --model "${M}" --out "${OUT}/d2.json" render >/dev/null 2>&1 +if python3 -c "import json;d=json.load(open('${OUT}/d2.json'));assert d['roles']['data-scientist']['schema_grants']['gold']['select'] is True"; then + ok "render menampilkan schema_grants" +else + bad "render tak memuat schema_grants" +fi +"${CLI}" --model "${M}" grant remove data-scientist 'gold.*' >/dev/null 2>&1 +out="$(cli show role data-scientist)" +case "${out}" in *"gold.*"*) bad "grant schema tak terhapus" ;; *) ok "grant schema terhapus" ;; 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 @@ -181,12 +194,43 @@ for i, (_, want) in enumerate(base_cases): print(f" keputusan[{i}] -> {got or '(undefined)'} (harap {want})") if got != want: fails += 1 + +# Blok luas data-driven: op di LUAR op_caps (simulasi level-kolom UPDATE/MERGE) +extra = [ + (tin("UpdateColumns", "iceberg", "bronze", "orders", ["id"], "u", ["data-engineer"]), "true"), + (tin("UpdateColumns", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "false"), + (tin("UpdateColumns", "iceberg", "other", "x", ["id"], "u", ["data-engineer"]), "false"), +] +te = build(extra) +for i, (_, want) in enumerate(extra): + got = ev(te, i) + print(f" luas[{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 + +# Grant tingkat-schema: data-analyst dapat 'gold.*' (select) +roles = json.loads((opa / "data.json").read_text())["roles"] +roles["data-analyst"]["schema_grants"] = {"gold": {"select": True, "columns": "*"}} +sch_cases = [ + (tin("SELECTFROMCOLUMNS", "iceberg", "gold", "orders_gold", ["id"], "u", ["data-analyst"]), "true"), + (tin("SELECTFROMCOLUMNS", "iceberg", "gold", "brand_new_table", ["id"], "u", ["data-analyst"]), "true"), + (tin("SELECTFROMCOLUMNS", "iceberg", "bronze", "orders", ["id"], "u", ["data-analyst"]), "false"), + (tin("SELECTFROMCOLUMNS", "iceberg", "silver", "orders_silver", ["order_date"], "u", ["data-analyst"]), "false"), + (tin("FILTERTABLES", "iceberg", "gold", "whatever", [], "u", ["data-analyst"]), "true"), +] +ts = build(sch_cases, {"roles": roles}) +for i, (_, want) in enumerate(sch_cases): + got = ev(ts, i) + print(f" schema[{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)"