Isolasi Baris — scope, row_scope, create_scope, assignments
Empat konstruk menjawab empat pertanyaan berbeda. Keempatnya tidak bisa saling menggantikan, dan mencampurnya adalah sumber kebocoran multi-outlet.
| Konstruk | Pertanyaan | Sifat |
|---|---|---|
scope | "Entity ini dipartisi oleh apa?" | Fakta model data — tidak memfilter |
row_scope | "Apa yang ditegakkan saat BACA?" | Ditegakkan server-side, fail closed |
create_scope | "Apa yang ditegakkan saat BUAT?" | Baris belum ada → dijepit dari rujukannya |
assignments | "Dari mana nilai itu berasal?" | Sumber nilai untuk from: session |
Kontrak normatifnya ada di spec/backend/01-core-basic §1.7. Halaman ini menjelaskan mengapa keempatnya ada, dan apa yang rusak bila dihilangkan, dengan contoh yang bisa diperiksa di examples/kafe/.
1. Mengapa empat, bukan satu
Kesalahan yang paling sering: menganggap type: relation pada branch_id sudah cukup, karena ia "sudah menunjuk ke cabang".
Relasi hanya menyatakan bentuk field — FK ke baris cabang, arah, dan kardinalitasnya (belongs_to / has_many / has_one). Tidak ada satu bit pun di sana yang mengatakan "baris entity ini dipartisi per cabang", "setiap baris wajib punya nilainya", atau "siapa yang menjaga pembaca tetap di dalamnya".
Tiga entity kafe dengan field yang sama persis membuktikannya:
# cafe-master.promo — branch_id KOSONG berarti "berlaku di semua cabang"
scope: { dimension: branch, field: branch_id, enforced: none }
# cafe-master.menu-item-price — harga berbeda per cabang, kasir dibatasi cabangnya
scope: { dimension: branch, field: branch_id, required: true }
row_scope: [{ field: branch_id, op: eq, from: session }]
# cafe-master.dining-table — dibaca tamu ANONIM dari token QR, tanpa sesi
scope: { dimension: branch, field: branch_id, required: true, enforced: external }Ketiganya mendeklarasikan branch_id dengan relation: {type: belongs_to, resource: cafe-master.branch}. Yang membedakan hanya scope.
flowchart LR
R["branch_id<br/>type: relation"] --> F["MENJAWAB<br/>'field ini bentuknya apa'"]
S["scope<br/>dimension + field + required + enforced"] --> G["MENJAWAB<br/>'dipartisi oleh apa,<br/>wajib isinya, siapa penegaknya'"]
F -.->|"tidak bisa menurunkan"| G2. scope — deklarasi partisi
scope:
{ dimension: branch, field: branch_id, required: true, enforced: external }| Kunci | Arti |
|---|---|
dimension | Nama dimensi partisi (^[a-z][a-z0-9_]*$). Menghubungkan entity ini ke assignments yang menjadi sumber nilainya. |
field | Field pembawa nilai dimensi. Juga dipakai sebagai nama atribut sesi default (lihat §5). |
required: true | Setiap baris wajib punya nilai dimensi. Divalidasi terhadap required field-nya. |
enforced | Siapa yang menjaga pembaca tetap di dalam dimensinya. |
required diikat ke bentuk datanya
Deklarasi yang menjanjikan lebih dari yang dipaksakan bentuk datanya ditolak:
scope: dimension "branch" is declared required, but field "branch_id" is not
required — every row would still be allowed to omit itenforced — memaksa keputusan "siapa penegaknya"
scope tanpa penegakan tidak bisa dibedakan dari dimensi yang memang lintas cabang — dan itulah cara isolasi multi-outlet bocor diam-diam. Karena itu scope menyatakan siapa penegaknya:
enforced | Arti | Validator menuntut |
|---|---|---|
session (default, bila absen) | atribut sesi membawanya | ada row_scope pada field itu, from: session |
route | parameter permintaan membawanya | ada row_scope pada field itu, from: route |
none | dimensi memang lintas cabang (mis. promo global) | tidak ada row_scope pada field itu |
external | ditegakkan di luar entity — grant publik / create_scope | pengecualian eksplisit |
Nilai yang tidak lengkap atau kontradiktif ditolak saat validasi:
scope: enforced "none" says the dimension is cross-cutting, but a row_scope on
"branch_id" is declared — remove one of the two
scope: dimension "branch" is declared enforced by "session", but no row_scope on
"branch_id" says so — add `row_scope: [{field: branch_id, from: session}]`, or
declare `enforced: none|external` if the dimension is deliberately not enforced herescope BUKAN filter — dan bukan saklar runtime
Untuk entity yang tidak punya row_scope (mis. dining-table), menghapus blok scope tidak mengubah satu pun respons. Yang hilang: manifest berhenti bisa menjawab "meja ini dipartisi per cabang, dan penegakannya di mana?" — sehingga satu-satunya jawaban yang tersisa adalah "audit kodenya".
Nilai enforced adalah deklarasi, bukan saklar: penegakan tetap apa pun yang dikatakan row_scope/grant.
3. row_scope — otorisasi baca
row_scope:
- { field: branch_id, op: eq, from: session }Dibaca server, dan tidak bisa dilebarkan lewat query string: nilai from: session menimpa filter klien pada field yang sama. Aturan mutlaknya: nilai yang tidak bisa diselesaikan tidak boleh berubah menjadi "tanpa filter" — permintaan gagal (403), bukan melebar.
| Sumber nilai | Cara menulis | Kalau tak terselesaikan |
|---|---|---|
| literal | value: "paid,in_kitchen" (from kosong) | ditolak validasi |
session | from: session — attr, atau scope.field | 403 (fail closed) |
route | from: route — param, default nama field | 403 (fail closed) |
sequenceDiagram
participant K as Klien (kasir JKT)
participant S as Server
participant D as DB
K->>S: GET /order?branch_id=<cabang LAIN>
S->>S: row_scope: field=branch_id, from=session
S->>S: attr = scope.field = "branch_id"<br/>value = token.attrs["branch_id"] = JKT
S->>D: WHERE branch_id = 'JKT' ← filter klien DITIMPA
D-->>S: baris JKT saja
S-->>K: 200 (tetap cabangnya sendiri)Bukan fixed_filters
fixed_filters pada Table/Kanban di-merge di browser dan nilainya statis dari manifest — ia tidak bisa berarti "cabang milik pengguna yang login", dan klien mana pun bisa menghilangkannya. row_scope adalah batas; fixed_filters adalah kenyamanan.
Tiga tempat row_scope hidup, dan ia di-AND
| Tempat | Lingkup | Contoh kafe |
|---|---|---|
| Entity | setiap pemanggil, setiap peran | kasir dan barista dibatasi ke cabangnya |
| Grant (peran) | satu peran saja | "hanya pesanan lunas yang masuk dapur" |
| Grant publik (App) | satu permukaan | katalog QR dibatasi token sesi tamu |
Ketiganya di-AND: makin banyak batasan, makin sempit hasilnya.
Mengapa batas per-peran perlu tempat sendiri: menaruh filter status di row_scope entity justru membutakan kasir terhadap draft yang sedang ia susun, dan mengunci dapur keluar dari pekerjaannya. Karena itu sebuah action di dalam grant boleh membawa row_scope-nya sendiri:
# roles.yaml — peran dapur hanya melihat pesanan lunas, di cabangnya
- page: "order-page"
actions:
- name: list
row_scope:
- { field: status, op: in, value: "paid,in_kitchen,ready,served" }
- { field: branch_id, op: eq, from: session }Pengecualian yang disengaja
{module}.{plural}.read_all— pemegangnya membaca lintas baris by design (pemilik workspace, auditor lintas cabang). Bagi mereka atribut yang tak bisa diselesaikan adalah keadaan normal, bukan error. Permission ini tidak punya route sendiri: ia kebijakan, tetapi tetap terdaftar agar "siapa yang boleh membaca lintas cabang" bisa dijawab dari daftar grant.- Pemanggil anonim ber-grant publik berscope — scope grant sudah membatasi barisnya, dan
from: sessiontak akan pernah resolve untuk tamu. Menegakkanrow_scopeentity di sini justru menolak semua baca (403), bukan menyaringnya. Itu sebabnyamenu-item-pricebisa hidup denganrow_scope(untuk staf) dan dibaca anonim lewat grant (untuk tamu).
Baris di luar batas dibaca sebagai TIDAK ADA (404), bukan 403
Batasnya tidak boleh menjadi oracle keberadaan: list menyembunyikan baris itu, jadi find/update/delete menjawab sama (404). 403 disimpan untuk batas yang tidak bisa diselesaikan (salah konfigurasi) — keadaan yang harus berisik.
4. create_scope — otorisasi tulis
Setiap scope lain membatasi bacaan, dan bacaan bisa dibatasi karena barisnya sudah ada. Pada create belum ada baris untuk difilter — penyimpanan tidak punya predikat di jalur tulis — sehingga tanpa konstruk ini pemanggil cukup mengirim nilai dimensi pilihannya sendiri.
create_scope:
# Cabang sesi baru = cabang meja yang dirujuknya.
- {
field: branch_id,
from: record,
ref_field: dining_table_id,
via: cafe-master.dining-table,
via_field: branch_id,
}Artinya: kalau payload membawa dining_table_id, branch_id harus sama dengan cabang meja itu.
Aturannya:
- Kondisional pada rujukannya. Diperiksa hanya bila payload membawa
ref_field. Pesanan kasir (walk-in/takeaway) tidak punya sesi meja, jadi tidak ada record untuk menurunkan cabangnya — dan deklarasinya tidak boleh berubah menjadi "setiap create wajib punya sesi meja". - Ketidakcocokan DITOLAK (403), tidak ditimpa diam-diam. Menimpa membuat permintaan dan baris tersimpan tidak sepakat tanpa cara bagi pemanggil untuk mengetahuinya.
- Rujukan yang tidak bisa diselesaikan adalah input buruk (422), bukan 403 — kelas yang sama dengan relasi menggantung.
- Fail closed. Rujukan tidak ada, atau record rujukan tanpa nilai dimensi → menolak.
Contohnya nyata di kafe: tanpa deklarasi ini, tamu anonim dapat mem-POST pesanan dengan branch_id cabang lain. state_machine menahan status (harus initial), bukan cabang.
5. assignments — dari mana nilai itu berasal
# pada entity yang mencatat penugasan: cafe-master.employee
assignments:
- { dimension: branch, field: branch_id, principal_field: username }Token boleh membawa atribut langsung di klaim attrs. Kalau tidak, server menyelesaikannya dari entity yang mendeklarasikan assignments: baris yang principal_field-nya sama dengan username pemanggil menentukan nilainya (mis. employee.username == "kasir" → employee.branch_id).
Inilah sebabnya row_scope: {from: session} cukup ditulis tanpa attr pada entity yang punya scope: nama atributnya = scope.field. Hasilnya di-memo sesaat (atribut yang berubah saat sesi berjalan berlaku tanpa login ulang).
Bila tidak ada satu pun sumber, formspec validate menolak di deploy time — daripada menemukan penyebabnya dari 403 saat runtime:
row_scope on "branch_id" resolves attribute "branch_id" from dimension "branch",
but no entity declares `assignments` for that dimension — every read would fail
closed (403). Declare `assignments: [{dimension: branch, field: <field carrying
the value>, principal_field: <field identifying the login>}]`, or name the token
attribute explicitly with `attr: <name>`.6. Contoh lengkap: satu tamu QR dari scan sampai pesanan tersimpan
1. Tamu memindai QR → GET /dining-table/JKT-A01-DEMO
├─ dining-table.scope.enforced = external
│ → row_scope entity TIDAK berlaku; baris dibaca anonim
└─ pembatasnya: qr_token itu sendiri (natural_key, tidak bisa ditebak)
2. Token → sesi → POST /table-session { branch_id: "{table.branch_id}" }
├─ branch_id datang dari KLIEN
└─ create_scope mengeceknya terhadap dining-table.branch_id
· cocok → 201
· beda → 403 ("di luar dimensimu")
· meja tak ada → 422 (relasi menggantung)
3. Baca menu → GET /menu-item-price
├─ row_scope entity DILEWATI (pemanggil anonim ber-grant)
└─ pembatasnya: scope grant publik, nilainya dari sesi tamu
4. Kasir login → GET /order
├─ token.attrs = { branch_id: "<KFE-JKT-01>" }
│ ← dari cafe-master.employee.assignments
└─ row_scope entity (default `session`): WHERE branch_id = '<KFE-JKT-01>'
· kasir menambah ?branch_id=<cabang lain> → DITIMPA, tetap JKTPerhatikan: langkah 1–3 memakai enforced: external, langkah 4 memakai default (session). Satu entity tidak bisa melayani keduanya dengan satu row_scope — itulah sebabnya pembatas per-permukaan ada.
7. Kalau dihilangkan, apa yang terjadi
| Deklarasi dihilangkan | Akibat | Kapan teramati |
|---|---|---|
scope pada entity terpartisi | tidak ada perubahan runtime; manifest tak lagi bisa menjawab "siapa penegaknya" | diam-diam |
enforced pada scope tanpa row_scope | validasi gagal (default session menuntut row_scope) | deploy time |
row_scope pada entity ter-scope | kasir cabang A melihat baris cabang B | runtime |
create_scope | tamu anonim menulis pesanan/sesi dengan branch_id cabang lain | runtime |
assignments | setiap baca ter-scope menjawab 403 (fail closed) | deploy time (validator) |
scope + row_scope pada grant publik | anonim membaca semua cabang (mis. harga seluruh cabang) | runtime |
8. Verifikasi
formspec validate # menolak scope/row_scope/grant yang tak punya sumber
go test ./pkg/spec/ -run TestValidateEntitySpec_Scope
go test ./internal/api/ -run RowScopeKontrak lengkap (termasuk bentuk grants dan aturan read_all): spec/backend/01-core-basic §1.7.