Middleware wajibMasuk memastikan setiap permintaan datang dari pengguna yang dikenal — tetapi semua pengguna yang dikenal belum tentu setara. Budi seharusnya tidak bisa menghapus tugas milik Siti, sementara admin memang ditugasi mengelola data milik semua orang. Role-Based Access Control (RBAC) menjawab kebutuhan ini dengan satu ide sederhana: setiap pengguna membawa daftar peran, dan setiap endpoint menyatakan peran apa saja yang boleh melewatinya. Pada chapter ini kita menyusun lapisan kedua di atas middleware autentikasi dari Chapter sebelumnya, lengkap dengan pabrik middleware wajibPeran dan penerapan status 403.

Peran sebagai Kelompok Izin #

Peran (role) adalah nama untuk sekelompok izin: admin berarti boleh mengelola pengguna dan data global, editor berarti boleh menulis konten, pengguna berarti hanya boleh mengelola datanya sendiri. Menyimpan peran sebagai array — peran: ['pengguna', 'admin'] — mengizinkan satu orang memegang beberapa peran sekaligus, misalnya moderator yang juga pengguna biasa.

Alur keputusannya kini dua lapis. Lapis pertama tetap wajibMasuk: tanpa identitas, membicarakan izin tidak bermakna, dan jawabannya 401. Lapis kedua adalah pemeriksa peran: identitas sudah diketahui tetapi perannya kurang, dan jawabannya 403 — mengulang permintaan dengan token yang sama tidak akan mengubah hasil karena masalahnya bukan autentikasi.

Pabrik Middleware wajibPeran #

Endpoint yang berbeda membutuhkan peran berbeda, jadi pemeriksanya dibuat oleh sebuah factory function: wajibPeran('admin') mengembalikan function yang menolak siapa pun tanpa peran admin. Konstruksi ini persis pola requirePeran('admin') pada Express yang akan kita temui di proyek nyata — parameter di luar menentukan kebijakan, function di dalam menjalankan pemeriksaan.

js
import http from 'node:http';

// Setiap pengguna membawa daftar peran, bukan satu peran tunggal.
const pengguna = new Map([
  ['token-budi-demo', { nama: 'Budi', peran: ['pengguna'] }],
  ['token-siti-demo', { nama: 'Siti', peran: ['pengguna', 'admin'] }],
]);

function wajibMasuk(req, res) {
  const otorisasi = req.headers.authorization ?? '';
  const [skema, token] = otorisasi.split(' ');
  if (skema === undefined || skema.toLowerCase() !== 'bearer' || !token) {
    res.writeHead(401, {
      'Content-Type': 'application/json',
      'WWW-Authenticate': 'Bearer realm="tugas"',
    });
    res.end(JSON.stringify({ error: 'Wajib masuk dulu (401)' }));
    return null;
  }
  const user = pengguna.get(token);
  if (user === undefined) {
    res.writeHead(401, {
      'Content-Type': 'application/json',
      'WWW-Authenticate': 'Bearer realm="tugas"',
    });
    res.end(JSON.stringify({ error: 'Token tidak dikenal (401)' }));
    return null;
  }
  return user;
}

// Middleware authorization: pabrik yang menghasilkan pemeriksa peran.
function wajibPeran(peranWajib) {
  return (user, res) => {
    if (!user.peran.includes(peranWajib)) {
      res.writeHead(403, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({
        error: `Butuh peran ${peranWajib} (dimiliki: ${user.peran.join(', ')})`,
      }));
      return false;
    }
    return true;
  };
}

const hanyaAdmin = wajibPeran('admin');

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://127.0.0.1`);

  // Semua pengguna yang sudah masuk boleh membaca.
  if (req.method === 'GET' && url.pathname === '/tugas') {
    const user = wajibMasuk(req, res);
    if (user === null) return;
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ pesan: `daftar tugas milik ${user.nama}` }));
  }

  // Hanya admin boleh menghapus.
  if (req.method === 'DELETE' && url.pathname === '/tugas/1') {
    const user = wajibMasuk(req, res);
    if (user === null) return;
    if (!hanyaAdmin(user, res)) return;
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ pesan: 'tugas 1 dihapus' }));
  }

  res.writeHead(404, { 'Content-Type': 'application/json' });
  return res.end(JSON.stringify({ error: 'Endpoint tidak ditemukan' }));
});

server.listen(0, '127.0.0.1', async () => {
  const basis = `http://127.0.0.1:${server.address().port}`;
  const budi = { Authorization: 'Bearer token-budi-demo' };
  const siti = { Authorization: 'Bearer token-siti-demo' };

  const r1 = await fetch(`${basis}/tugas/1`, { method: 'DELETE' });
  console.log('tamu  DELETE /tugas/1 :', r1.status, await r1.text());

  const r2 = await fetch(`${basis}/tugas/1`, { method: 'DELETE', headers: budi });
  console.log('Budi  DELETE /tugas/1 :', r2.status, await r2.text());

  const r3 = await fetch(`${basis}/tugas/1`, { method: 'DELETE', headers: siti });
  console.log('Siti  DELETE /tugas/1 :', r3.status, await r3.text());

  const r4 = await fetch(`${basis}/tugas`, { headers: budi });
  console.log('Budi  GET /tugas      :', r4.status, await r4.text());

  server.close();
});

Output yang Dihasilkan:

text
tamu  DELETE /tugas/1 : 401 {"error":"Wajib masuk dulu (401)"}
Budi  DELETE /tugas/1 : 403 {"error":"Butuh peran admin (dimiliki: pengguna)"}
Siti  DELETE /tugas/1 : 200 {"pesan":"tugas 1 dihapus"}
Budi  GET /tugas      : 200 {"pesan":"daftar tugas milik Budi"}

Mari kita bedah baris-per-baris bagian intinya:

  1. Baris 4-7 (data pengguna): Budi hanya membawa ['pengguna'] sedangkan Siti membawa ['pengguna', 'admin'] — dua array berbeda, dua tingkat akses, tanpa satu pun cabang if nama === 'Siti' di kode handler.
  2. Baris 33-44 (wajibPeran): function luar menerima peranWajib dan mengembalikan function penutup yang menyimpan nilai itu. Setiap pemanggilan wajibPeran('editor') menghasilkan pemeriksa baru untuk peran berbeda tanpa duplikasi kode.
  3. Baris 36-40 (penolakan 403): response menyebutkan peran yang dibutuhkan dan peran yang dimiliki — pesan yang membantu pengembang melakukan debugging, namun tetap tidak membocorkan hal sensitif karena informasi peran memang milik pengguna yang bertanya.
  4. Baris 59-66 (handler DELETE): urutan dua gerbang tampak nyata — wajibMasuk lebih dulu (401 bila gagal), hanyaAdmin menyusul (403 bila gagal). Keduanya mengirim response sendiri sehingga handler hanya perlu memeriksa hasil null dan false.
  5. Baris 77-87 (penguji): empat permintaan memetakan seluruh matriks — tamu ditolak 401, Budi ditolak 403, Siti lolos 200, dan Budi tetap bisa membaca daftar tugas karena endpoint itu hanya meminta gerbang pertama.

Peran versus Izin Langsung #

RBAC bukan satu-satunya model. Ujung lainnya adalah menyimpan izin (permission) satu per satu pada pengguna: tugas:baca, tugas:hapus, pengguna:kelola. Tabel berikut memetakan tarik-menarik keduanya:

AspekBerbasis peranBerbasis izin langsung
Kemudahan dipahamiTinggi — “admin” terdengar jelas bagi tim dan penggunaRendah — daftar izin panjang harus dibaca satu per satu
Perubahan kebijakanUbah definisi peran sekali, semua pemegang peran terdampakUbah tiap pengguna yang terdampak
Ketelitian aksesKasar — satu peran membawa semua izin di dalamnyaHalus — bisa mencabut satu izin tanpa menyentuh lainnya
Jumlah baris dataSedikit — satu array per penggunaBanyak — satu baris per izin per pengguna

Praktik umum menggabungkan keduanya: pengguna menyimpan peran, peran menyimpan daftar izin, dan endpoint memeriksa izin. Dengan begitu kenyamanan peran tetap terasa sementara pengecualian halus tetap mungkin tanpa menciptakan peran baru untuk setiap kombinasi.

Dua gerbang kini lengkap: identitas lewat token, izin lewat peran. Pertanyaan terakhir Section ini bergeser ke arah yang berbeda — bagaimana bila pengguna kita ingin masuk tanpa membuat sandi baru sama sekali, memakai akun Google atau GitHub yang sudah ada? Itulah wilayah OAuth 2.0 pada Chapter berikutnya.

Perilaku resmi status penolakan peran terdokumentasi pada status 403 Forbidden di MDN.