Skip to content

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.

KonstrukPertanyaanSifat
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:

yaml
# 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.

mermaid
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"| G

2. scope — deklarasi partisi ​

yaml
scope:
  { dimension: branch, field: branch_id, required: true, enforced: external }
KunciArti
dimensionNama dimensi partisi (^[a-z][a-z0-9_]*$). Menghubungkan entity ini ke assignments yang menjadi sumber nilainya.
fieldField pembawa nilai dimensi. Juga dipakai sebagai nama atribut sesi default (lihat §5).
required: trueSetiap baris wajib punya nilai dimensi. Divalidasi terhadap required field-nya.
enforcedSiapa yang menjaga pembaca tetap di dalam dimensinya.

required diikat ke bentuk datanya ​

Deklarasi yang menjanjikan lebih dari yang dipaksakan bentuk datanya ditolak:

text
scope: dimension "branch" is declared required, but field "branch_id" is not
required — every row would still be allowed to omit it

enforced — 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:

enforcedArtiValidator menuntut
session (default, bila absen)atribut sesi membawanyaada row_scope pada field itu, from: session
routeparameter permintaan membawanyaada row_scope pada field itu, from: route
nonedimensi memang lintas cabang (mis. promo global)tidak ada row_scope pada field itu
externalditegakkan di luar entity — grant publik / create_scopepengecualian eksplisit

Nilai yang tidak lengkap atau kontradiktif ditolak saat validasi:

text
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 here

scope 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 ​

yaml
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 nilaiCara menulisKalau tak terselesaikan
literalvalue: "paid,in_kitchen" (from kosong)ditolak validasi
sessionfrom: session — attr, atau scope.field403 (fail closed)
routefrom: route — param, default nama field403 (fail closed)
mermaid
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 ​

TempatLingkupContoh kafe
Entitysetiap pemanggil, setiap perankasir dan barista dibatasi ke cabangnya
Grant (peran)satu peran saja"hanya pesanan lunas yang masuk dapur"
Grant publik (App)satu permukaankatalog 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:

yaml
# 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: session tak akan pernah resolve untuk tamu. Menegakkan row_scope entity di sini justru menolak semua baca (403), bukan menyaringnya. Itu sebabnya menu-item-price bisa hidup dengan row_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.

yaml
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 ​

yaml
# 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:

text
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 ​

text
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 JKT

Perhatikan: 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 dihilangkanAkibatKapan teramati
scope pada entity terpartisitidak ada perubahan runtime; manifest tak lagi bisa menjawab "siapa penegaknya"diam-diam
enforced pada scope tanpa row_scopevalidasi gagal (default session menuntut row_scope)deploy time
row_scope pada entity ter-scopekasir cabang A melihat baris cabang Bruntime
create_scopetamu anonim menulis pesanan/sesi dengan branch_id cabang lainruntime
assignmentssetiap baca ter-scope menjawab 403 (fail closed)deploy time (validator)
scope + row_scope pada grant publikanonim membaca semua cabang (mis. harga seluruh cabang)runtime

8. Verifikasi ​

bash
formspec validate          # menolak scope/row_scope/grant yang tak punya sumber
go test ./pkg/spec/ -run TestValidateEntitySpec_Scope
go test ./internal/api/ -run RowScope

Kontrak lengkap (termasuk bentuk grants dan aturan read_all): spec/backend/01-core-basic §1.7.

Standar terbuka (CC0) dengan implementasi referensi.