Autentikasi menjawab pertanyaan siapa penggunanya; otorisasi menjawab pertanyaan apa yang boleh dilakukan pengguna tersebut. Model otorisasi yang paling banyak dipakai adalah Role-Based Access Control (RBAC): setiap pengguna membawa satu atau lebih role (peran) seperti admin, editor, atau user, dan aplikasi memeriksa peran tersebut sebelum membiarkan sebuah aksi terjadi. Pada chapter ini kita akan membangun pemeriksaan peran dari yang paling sederhana, mengenal jebakan hierarki peran, lalu menyimpan peran di database agar bisa dikelola tanpa mengubah kode.

Peran sebagai Label Hak Akses #

Peran adalah label yang mewakili sekelompok hak akses. Editor boleh menyunting dan menerbitkan artikel, admin boleh mengelola pengguna, dan user biasa hanya boleh membaca. Pemeriksaan paling dasarnya cukup memakai in_array():

php
<?php
function hasRole(array $userRoles, string $needed): bool
{
    return in_array($needed, $userRoles, true);
}

$rolesBudi = ["user"];
$rolesSiti = ["editor", "user"];

var_dump(hasRole($rolesBudi, "editor"));
var_dump(hasRole($rolesSiti, "editor"));
var_dump(hasRole($rolesSiti, "admin"));

Output yang Dihasilkan:

text
bool(false)
bool(true)
bool(false)

Peran pengguna disimpan sebagai array, bukan string tunggal, karena satu orang boleh memegang beberapa peran sekaligus — Siti di atas adalah editor sekaligus user. Argumen ketiga true pada in_array() memaksa perbandingan strict (===), sehingga peran "editor" hanya cocok dengan string yang persis sama tanpa koersi tipe. Pilihan sederhana ini sudah cukup untuk aplikasi kecil dengan dua atau tiga peran tetap.

Guard yang Memeriksa Peran #

Di aplikasi nyata, pemeriksaan peran biasanya dibungkus menjadi guard — fungsi atau middleware yang dipanggil di awal handler, persis pola yang kita pakai untuk memeriksa login pada chapter Login dan Logout . Guard menerima daftar peran yang diizinkan untuk sebuah aksi, dan menolak selebihnya:

php
<?php
function requireRole(array $userRoles, string $action, array $allowedRoles): void
{
    foreach ($allowedRoles as $allowed) {
        if (in_array($allowed, $userRoles, true)) {
            echo "Akses '$action' diizinkan\n";
            return;
        }
    }

    throw new RuntimeException("Akses '$action' ditolak");
}

$rolesBudi = ["user"];

try {
    requireRole($rolesBudi, "hapus artikel", ["admin", "editor"]);
} catch (RuntimeException $e) {
    echo "Error: " . $e->getMessage() . "\n";
}

requireRole(["editor"], "sunting artikel", ["admin", "editor"]);

Output yang Dihasilkan:

text
Error: Akses 'hapus artikel' ditolak
Akses 'sunting artikel' diizinkan

Mari kita bedah perilakunya:

  1. requireRole() mengiterasi daftar peran yang diizinkan untuk aksi tersebut; jika salah satu cocok dengan peran milik pengguna, akses diizinkan dan fungsi selesai.
  2. Jika tidak ada yang cocok, fungsi melempar RuntimeException — di aplikasi web nyata, handler akan menangkap pengecualian ini dan merespons HTTP 403 Forbidden.
  3. Panggilan pertama gagal karena Budi hanya user, sedangkan menghapus artikel butuh admin atau editor.
  4. Panggilan kedua berhasil karena pemanggil membawa peran editor yang memang diizinkan.

Pola “aksi + daftar peran yang diizinkan” seperti ini lebih aman daripada pemeriksaan if yang tersebar, karena daftar peran yang diizinkan berada tepat di titik keputusan, bukan tersebar implisit di mana-mana.

Jebakan Hierarki Peran #

Banyak tim membayangkan peran sebagai tangga: admin di atas editor, editor di atas user. Bayangan ini kemudian mengendap menjadi kode seperti berikut:

php
<?php
// Pola rapuh: daftar peran "yang boleh" tersebar di banyak berkas.
function bolehHapusArtikel(string $role): bool
{
    return $role === "admin" || $role === "editor";
}

function bolehLihatStatistik(string $role): bool
{
    return $role === "admin" || $role === "editor" || $role === "moderator";
}

// Saat peran baru ditambahkan, setiap fungsi seperti ini harus
// dicari dan diperbarui satu per satu — mudah ada yang terlewat.

Masalahnya muncul saat peran baru masuk. Ketika moderator ditambahkan, tim harus menelusuri semua fungsi serupa dan memutuskan satu per satu apakah moderator boleh ikut. Satu fungsi yang terlewat berarti moderator diam-diam tidak bisa melakukan aksi yang seharusnya boleh — atau sebaliknya, kelebihan akses. Hierarki peran juga cepat kehilangan maknanya ketika kebutuhan dunia nyata tidak bertangga: “editor rubrik olahraga” tidak bisa dinyatakan dengan rapi dalam hierarki tunggal. Solusi yang lebih tahan banting adalah mendefinisikan hak akses secara eksplisit per aksi — arah yang akan kita tempuh di chapter Permission Modeling .

Peran Terketik dengan Enum #

String peran mudah salah ketik: "Admin", "admin ", dan "admim" semuanya lolos sebagai nilai array tanpa protes dari PHP. PHP 8.1 memberi kita enum untuk membuat kesalahan seperti itu mustahil:

php
<?php
enum Role: string
{
    case Admin = "admin";
    case Editor = "editor";
    case User = "user";
}

function hasRole(array $userRoles, Role $needed): bool
{
    return in_array($needed, $userRoles, true);
}

$rolesSiti = [Role::Editor, Role::User];

var_dump(hasRole($rolesSiti, Role::Editor));
var_dump(hasRole($rolesSiti, Role::Admin));

Output yang Dihasilkan:

text
bool(true)
bool(false)

Role adalah backed enum — setiap case membawa nilai string yang nanti disimpan di database. Tipe pada parameter Role $needed menjamin hanya tiga case yang valid bisa masuk; Role::Admim akan langsung menjadi error parse, bukan bug senyap. Nilai string di backing tetap dipakai sebagai format penyimpanan, sementara kode aplikasi bekerja dengan case yang terketik penuh.

Menyimpan Peran di Database #

Sampai titik ini peran masih hardcode di PHP. Agar peran bisa diberikan atau dicabut dari panel admin tanpa deploy ulang, simpan peran di tabel junction user_roles:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    nama TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE user_roles (
    user_id INTEGER NOT NULL,
    role TEXT NOT NULL,
    PRIMARY KEY (user_id, role)
)");

function assignRole(PDO $pdo, int $userId, string $role): void
{
    // INSERT OR IGNORE membuat penugasan ulang bersifat idempoten.
    $stmt = $pdo->prepare(
        "INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)"
    );
    $stmt->execute([$userId, $role]);
}

function getRoles(PDO $pdo, int $userId): array
{
    $stmt = $pdo->prepare("SELECT role FROM user_roles WHERE user_id = ? ORDER BY role");
    $stmt->execute([$userId]);

    return $stmt->fetchAll(PDO::FETCH_COLUMN);
}

$pdo->exec("INSERT INTO users (email, nama) VALUES ('[email protected]', 'Budi')");
$pdo->exec("INSERT INTO users (email, nama) VALUES ('[email protected]', 'Siti')");

assignRole($pdo, 1, "user");
assignRole($pdo, 2, "editor");
assignRole($pdo, 2, "user");
assignRole($pdo, 2, "user"); // duplikat diabaikan

echo "Peran Budi: " . implode(", ", getRoles($pdo, 1)) . "\n";
echo "Peran Siti: " . implode(", ", getRoles($pdo, 2)) . "\n";
echo "Siti editor? " . (in_array("editor", getRoles($pdo, 2), true) ? "ya" : "tidak") . "\n";

Output yang Dihasilkan:

text
Peran Budi: user
Peran Siti: editor, user
Siti editor? ya

Tabel user_roles berisi pasangan user_id dan role dengan primary key gabungan, sehingga kombinasi yang sama tidak pernah tercatat dua kali. assignRole() memakai INSERT OR IGNORE (sintaks SQLite; PostgreSQL memakai INSERT ... ON CONFLICT DO NOTHING) supaya penugasan yang diulang tidak melempar error. getRoles() mengembalikan daftar peran milik satu user, siap dimasukkan ke guard hasRole() yang kita bangun di awal chapter. Saat pengguna login, ambil daftar peran ini sekali lalu simpan di session agar tidak ada query tambahan di setiap permintaan.

Matriks Aksi sebagai Peta Otorisasi #

Ketika jumlah aksi dan peran bertambah, kita butuh satu tempat yang memetakan seluruh kebijakan. Matriks berupa array asosiatif memenuhi kebutuhan itu:

php
<?php
$matriksAksi = [
    "admin"  => ["artikel.hapus", "artikel.sunting", "artikel.lihat", "user.kelola"],
    "editor" => ["artikel.sunting", "artikel.lihat"],
    "user"   => ["artikel.lihat"],
];

function bolehAksi(array $matriksAksi, string $role, string $aksi): bool
{
    $aksiPeran = $matriksAksi[$role] ?? [];

    return in_array($aksi, $aksiPeran, true);
}

var_dump(bolehAksi($matriksAksi, "editor", "artikel.sunting"));
var_dump(bolehAksi($matriksAksi, "editor", "user.kelola"));
var_dump(bolehAksi($matriksAksi, "user", "artikel.lihat"));
var_dump(bolehAksi($matriksAksi, "moderator", "artikel.lihat"));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(true)
bool(false)

Menambahkan peran baru kini berarti menambah satu baris di satu tempat, dan semua guard otomatis mengikuti. Perhatikan dua detail pentingnya: operator ?? mengembalikan array kosong untuk peran yang tidak dikenal (moderator), sehingga secara bawaan akses ditolak — prinsip deny by default — dan nilai aksi memakai format kategori.aksi yang mengingatkan kita pada satuan yang lebih halus lagi. Nilai itulah, bukan nama peran, yang sebenarnya ingin kita periksa; chapter berikutnya mempromosikannya menjadi kelas warga negara penuh bernama permission.

Referensi #

Dokumentasi resmi untuk pendalaman lebih lanjut:

Ringkasan #

RBAC memberi aplikasi kosakata otorisasi yang ringkas: pengguna membawa peran, guard membandingkan peran dengan daftar yang diizinkan, dan tabel user_roles membuat penugasan peran dapat dikelola tanpa mengubah kode. Kita juga melihat mengapa hierarki peran yang tersirat dan pemeriksaan yang tersebar menjadi sumber bug otorisasi, serta bagaimana enum dan matriks aksi menegakkannya kembali. Matriks di akhir chapter sebenarnya sudah membocorkan arah berikutnya: ganti label peran dengan daftar permission yang eksplisit — dibahas tuntas pada chapter Permission Modeling .