feat sicabe-authz
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
#
|
||||
# Grup user berasal dari LDAP group provider Trino (OpenLDAP, cn = data-*).
|
||||
# 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: "*" | [..] }
|
||||
# - 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"
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
Executable
+83
@@ -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
|
||||
Executable
+188
@@ -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
|
||||
Reference in New Issue
Block a user