# ============================================================================= # OPA — policy Trino access-control (package trino) — OPA-ONLY # ----------------------------------------------------------------------------- # Trino (access-control.name=opa) mengirim satu request per operasi: # POST http://opa:8181/v1/data/trino/allow # input.context.identity = { "user": "...", "groups": [...] } # input.action.operation = "SELECT" | "INSERT" | ... # input.action.resource = { catalogName, schemaName, tableName, columns } # OPA mengembalikan { "result": { "allow": true|false } }. # # 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 # - write : INSERT/UPDATE/DELETE/MERGE # - ddl : CREATE/DROP/ALTER/... TABLE # Kolom: bila columns != "*", SELECT hanya boleh menyebut kolom tsb. # deny-by-default. # ============================================================================= package trino import rego.v1 default allow := false # Normalisasi operasi: uppercase, buang spasi & underscore. 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 { some g in identity_groups r := g } effective_roles contains r if { r := data.users[identity_user] } # Grup 'data-admin' = superuser (bypass semua pembatasan data/sistem). is_super if { some r in effective_roles r == "data-admin" } # ── CREATE/DROP SCHEMA (DDL level-schema) ─────────────────────────────────── # Trino mengirim operasi ini tanpa resource tabel → tak bisa memakai grant # per-tabel di op_caps. Izinkan untuk peran engineering (data-admin/engineer). allow if { op in {"CREATESCHEMA", "DROPSCHEMA"} some r in effective_roles r in {"data-admin", "data-engineer"} } # ── Pemetaan operasi -> kemampuan yang dibutuhkan ─────────────────────────── # SELECTFROMCOLUMNS TIDAK ada di sini (ditangani khusus + cek kolom). op_caps := { "SHOWCOLUMNS": "select", "DESCRIBE": "select", "INSERTINTOTABLE": "write", "UPDATE": "write", "DELETE": "write", "MERGE": "write", "CREATETABLE": "ddl", "DROPTABLE": "ddl", "TRUNCATETABLE": "ddl", "ALTERTABLE": "ddl", "RENAMETABLE": "ddl", "ADDCOLUMN": "ddl", "DROPCOLUMN": "ddl", "RENAMECOLUMN": "ddl", "COMMENTTABLE": "ddl", } # ── Operasi metadata yang diizinkan utk user terautentikasi ──────────────── meta_op(opx) if startswith(opx, "SHOW") meta_op(opx) if opx in {"USE", "SETSESSION", "RESETSESSION", "SETROLE", "SHOWROLES", "SHOWCURRENTROLES"} # ── Eksekusi query (level sistem) ─────────────────────────────────────────── allow if op == "EXECUTEQUERY" # ── Operasi metadata SHOW/USE/... utk user terautentikasi ────────────────── allow if meta_op(op) # ── Superuser ─────────────────────────────────────────────────────────────── allow if is_super # ── Schema sistem/metadata (listing/autocomplete; isi data tetap terbatas) ─ allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource res.table.catalogName == "system" res.table.schemaName in {"jdbc", "metadata", "information_schema"} } allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource res.table.schemaName == "information_schema" } # ── Akses & listing catalog / schema ──────────────────────────────────────── allow if { op in {"ACCESSCATALOG", "FILTERCATALOGS"} res := input.action.resource res.catalog.name in allowed_catalogs } allow if { op == "FILTERSCHEMAS" res := input.action.resource res.schema.catalogName in allowed_catalogs } allow if { op == "FILTERTABLES" res := input.action.resource res.table.catalogName == "system" res.table.schemaName in {"jdbc", "metadata", "information_schema"} } # ── Operasi data/metadata per tabel (non-SELECT) ──────────────────────────── # dbt membuat tabel sementara __dbt_tmp (dan backup __dbt_backup): # cocokkan ke grant tabel dasarnya. dbt_suffixes := {"__dbt_tmp", "__dbt_backup"} has_dbt_suffix(name) if { some s in dbt_suffixes endswith(name, s) } base_table_name(name) := n if { some s in dbt_suffixes endswith(name, s) n := substring(name, 0, count(name) - count(s)) } base_table_name(name) := name if { not has_dbt_suffix(name) } table_id(schema, table) := sprintf("%s_%s", [schema, base_table_name(table)]) allow if { cap := op_caps[op] res := input.action.resource tid := table_id(res.table.schemaName, res.table.tableName) some r in effective_roles grant := data.roles[r].tables[tid] grant[cap] == true } # ── SELECT ... (per-kolom) ────────────────────────────────────────────────── # Semua kolom yang diminta harus diizinkan oleh grant.columns. cols_all_ok(columns, cols) if columns == "*" cols_all_ok(columns, cols) if { columns != "*" every c in cols { c in columns } } allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource res.table.catalogName in data_catalogs tid := table_id(res.table.schemaName, res.table.tableName) cols := res.table.columns count(cols) > 0 some r in effective_roles grant := data.roles[r].tables[tid] grant.select == true cols_all_ok(grant.columns, cols) } # ── SELECT tanpa kolom eksplisit (mis. count(*)) ──────────────────────────── # Trino mengirim columns: [] untuk count(*)/agregasi tanpa kolom. Izinkan bila # user punya 'select' pada tabel tsb (tidak membocorkan isi kolom). Tanpa rule # ini, count(*) selalu ditolak karena rule SELECT di atas butuh count(cols) > 0. allow if { op == "SELECTFROMCOLUMNS" res := input.action.resource 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 grant := data.roles[r].tables[tid] grant.select == true } # ── FilterColumns — daftar kolom utk metadata (information_schema/SHOW COLUMNS) # Trino memakai operasi ini untuk memfilter kolom yang boleh DILIHAT. Tanpa rule # ini (deny-by-default) SEMUA kolom tersembunyi dari information_schema, # `SHOW COLUMNS`, dan `DESCRIBE` — padahal SELECT-nya sendiri diizinkan. Akibat # nyata: katalog metadata (mis. ingestion OpenMetadata) melihat tabel TANPA kolom. # Batas per-kolom tetap dihormati lewat cols_all_ok (least privilege). allow if { op == "FILTERCOLUMNS" res := input.action.resource res.table.schemaName == "information_schema" } allow if { op == "FILTERCOLUMNS" res := input.action.resource res.table.catalogName == "system" } allow if { op == "FILTERCOLUMNS" res := input.action.resource tid := table_id(res.table.schemaName, res.table.tableName) some r in effective_roles grant := data.roles[r].tables[tid] grant.select == true grant.columns == "*" } allow if { op == "FILTERCOLUMNS" res := input.action.resource tid := table_id(res.table.schemaName, res.table.tableName) cols := res.table.columns count(cols) > 0 some r in effective_roles grant := data.roles[r].tables[tid] grant.select == true cols_all_ok(grant.columns, cols) } # ── Listing tabel (FilterTables): tabel terlihat bila ada grant apapun ───── any_cap(grant) if { some cap in ["select", "write", "ddl"] grant[cap] == true } allow if { op == "FILTERTABLES" res := input.action.resource 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] any_cap(grant) } 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. # # ⚠️ 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. allow if { res := input.action.resource res.table.schemaName in write_schemas some r in effective_roles r == "data-engineer" }