Role menjawab “peran apa pengguna ini?”, tetapi sering kali pertanyaan yang sesungguhnya adalah “bolehkah aksi ini dilakukan?”. Ketika kebutuhan melebihi tiga peran sederhana — editor khusus rubrik olahraga, penulis tamu yang hanya boleh menyimpan draf, moderator yang boleh menghapus komentar tapi bukan artikel — daftar peran membengkak jadi puluhan kombinasi yang tidak lagi bisa diingat siapa pun. Permission modeling membalik pendekatannya: definisikan hak aksi sebagai satuan kecil yang bernama, lalu biarkan role menjadi sekadar bundel dari satuan-satuan itu. Pada chapter ini kita bangun tabel permission, menghubungkannya ke role dan user, lalu menangani wildcard dan aturan deny.

Permission sebagai Satuan Hak Akses #

Permission adalah nama untuk satu aksi spesifik, biasanya dengan format kategori.aksi seperti posts.publish atau users.manage. Perbedaan mendasarnya dengan role:

AspekRolePermission
GranularitasKasar — sekelompok hakHalus — satu aksi spesifik
Contoh nilaieditorposts.publish
Kode memeriksa“peran apa pengguna ini?”“bolehkah aksi ini?”
Perubahan kebijakanSunting isi bundel, dampak massalTambah/hapus satu baris

Dalam bentuk paling sederhana, role hanyalah array yang memetakan nama peran ke daftar permission-nya:

php
<?php
$rolePermissions = [
    "admin"  => ["posts.create", "posts.publish", "posts.delete", "users.manage"],
    "editor" => ["posts.create", "posts.publish"],
    "author" => ["posts.create"],
];

function roleCan(array $rolePermissions, string $role, string $permission): bool
{
    return in_array($permission, $rolePermissions[$role] ?? [], true);
}

var_dump(roleCan($rolePermissions, "author", "posts.create"));
var_dump(roleCan($rolePermissions, "author", "posts.publish"));
var_dump(roleCan($rolePermissions, "admin", "users.manage"));

Output yang Dihasilkan:

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

Kode di atas memperlihatkan hubungan inti model ini: author boleh posts.create tetapi tidak boleh posts.publish. Jika besok kebijakan berubah dan author ikut boleh publish, kita cukup menambahkan satu string ke bundel — tanpa menyentuh guard di handler mana pun, karena handler hanya bertanya roleCan(...).

Tabel Permission dan Tabel Junction #

Untuk kebijakan yang bisa diubah dari panel admin, permission perlu hidup di database. Skemanya melibatkan tiga tabel: permissions menyimpan daftar satuan hak, roles menyimpan daftar peran, dan role_permissions menjadi tabel junction yang memetakan keduanya — pola many-to-many yang sama dengan yang kita pakai pada user_roles di chapter sebelumnya:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE roles (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE permissions (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE role_permissions (
    role_id INTEGER NOT NULL,
    permission_id INTEGER NOT NULL,
    PRIMARY KEY (role_id, permission_id)
)");

$roles = ["admin" => 1, "editor" => 2, "author" => 3];
$permissions = [
    "posts.create" => 1,
    "posts.publish" => 2,
    "posts.delete" => 3,
    "users.manage" => 4,
];
$petaRolePermission = [
    1 => [1, 2, 3, 4], // admin
    2 => [1, 2],       // editor
    3 => [1],          // author
];

foreach ($roles as $nama => $id) {
    $pdo->prepare("INSERT INTO roles (id, nama) VALUES (?, ?)")->execute([$id, $nama]);
}
foreach ($permissions as $nama => $id) {
    $pdo->prepare("INSERT INTO permissions (id, nama) VALUES (?, ?)")->execute([$id, $nama]);
}
foreach ($petaRolePermission as $roleId => $permissionIds) {
    foreach ($permissionIds as $permissionId) {
        $pdo->prepare("INSERT INTO role_permissions VALUES (?, ?)")
            ->execute([$roleId, $permissionId]);
    }
}

$sql = "SELECT p.nama
        FROM roles r
        JOIN role_permissions rp ON rp.role_id = r.id
        JOIN permissions p ON p.id = rp.permission_id
        WHERE r.nama = ?
        ORDER BY p.nama";

$stmt = $pdo->prepare($sql);
$stmt->execute(["editor"]);
echo "Permission role editor: " . implode(", ", $stmt->fetchAll(PDO::FETCH_COLUMN)) . "\n";

Output yang Dihasilkan:

text
Permission role editor: posts.create, posts.publish

Tabel role_permissions hanya berisi pasangan ID dengan primary key gabungan, sehingga relasi yang sama tidak tercatat dua kali. Query terakhir merangkai tiga tabel dengan dua JOIN untuk menjawab pertanyaan “permission apa saja milik role editor?” — hasilnya persis bundel yang tadi kita hardcode sebagai array. Menambah permission baru kini berarti menambahkan baris di permissions dan relasinya di role_permissions; keduanya operasi data biasa.

Memeriksa Permission Milik Pengguna #

Pertanyaan yang paling sering diajukan guard bukan “permission apa milik role ini?” melainkan “bolehkah user ini melakukan aksi ini?”. Jalurnya: dari user ke role (tabel user_roles), dari role ke permission (tabel role_permissions), lalu cocokkan namanya:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE roles (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE permissions (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE role_permissions (
    role_id INTEGER NOT NULL,
    permission_id INTEGER NOT NULL,
    PRIMARY KEY (role_id, permission_id)
)");
$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_id INTEGER NOT NULL,
    PRIMARY KEY (user_id, role_id)
)");

$roles = ["admin" => 1, "editor" => 2, "author" => 3];
$permissions = [
    "posts.create" => 1,
    "posts.publish" => 2,
    "posts.delete" => 3,
    "users.manage" => 4,
];
$petaRolePermission = [
    1 => [1, 2, 3, 4], // admin
    2 => [1, 2],       // editor
    3 => [1],          // author
];

foreach ($roles as $nama => $id) {
    $pdo->prepare("INSERT INTO roles (id, nama) VALUES (?, ?)")->execute([$id, $nama]);
}
foreach ($permissions as $nama => $id) {
    $pdo->prepare("INSERT INTO permissions (id, nama) VALUES (?, ?)")->execute([$id, $nama]);
}
foreach ($petaRolePermission as $roleId => $permissionIds) {
    foreach ($permissionIds as $permissionId) {
        $pdo->prepare("INSERT INTO role_permissions VALUES (?, ?)")
            ->execute([$roleId, $permissionId]);
    }
}

$pdo->exec("INSERT INTO users (email, nama) VALUES ('[email protected]', 'Budi')");
$pdo->exec("INSERT INTO users (email, nama) VALUES ('[email protected]', 'Siti')");
$pdo->exec("INSERT INTO user_roles (user_id, role_id) VALUES (1, 3)"); // Budi = author
$pdo->exec("INSERT INTO user_roles (user_id, role_id) VALUES (2, 1)"); // Siti = admin

function userHasPermission(PDO $pdo, int $userId, string $permission): bool
{
    $sql = "SELECT COUNT(*)
            FROM user_roles ur
            JOIN role_permissions rp ON rp.role_id = ur.role_id
            JOIN permissions p ON p.id = rp.permission_id
            WHERE ur.user_id = ? AND p.nama = ?";

    $stmt = $pdo->prepare($sql);
    $stmt->execute([$userId, $permission]);

    return $stmt->fetchColumn() > 0;
}

$uji = [
    ["Budi", 1, "posts.create"],
    ["Budi", 1, "posts.delete"],
    ["Siti", 2, "users.manage"],
];

foreach ($uji as [$nama, $userId, $permission]) {
    echo "$nama - $permission: "
        . (userHasPermission($pdo, $userId, $permission) ? "ya" : "tidak") . "\n";
}

Output yang Dihasilkan:

text
Budi - posts.create: ya
Budi - posts.delete: tidak
Siti - users.manage: ya

Fungsi userHasPermission() menghitung baris yang lolos rantai tiga JOIN dari user_roles sampai permissions dengan dua syarat: ID user dan nama permission. Budi yang berperan author lolos untuk posts.create dan ditolak untuk posts.delete; Siti yang admin lolos untuk users.manage. Handler tidak lagi tahu peran apa pun — handler hanya bertanya permission, dan jawabannya konsisten berapa pun cara user mendapatkannya.

Permission Wildcard #

Bayangkan admin dengan 40 permission terkait postingan: posts.view, posts.create, posts.edit, dan seterusnya. Mencantumkan semuanya satu per satu melelahkan dan mudah ketinggalan satu. Wildcard posts.* meringkasnya: satu entri berarti semua permission yang berawalan posts.:

php
<?php
function matchPermission(string $granted, string $needed): bool
{
    if ($granted === $needed) {
        return true;
    }
    if (str_ends_with($granted, ".*")) {
        return str_starts_with($needed, substr($granted, 0, -1));
    }

    return false;
}

function hasWildcardPermission(array $grantedList, string $needed): bool
{
    foreach ($grantedList as $granted) {
        if (matchPermission($granted, $needed)) {
            return true;
        }
    }

    return false;
}

$grantedSiti = ["posts.*", "comments.moderate", "users.view"];

foreach (["posts.create", "posts.delete", "comments.moderate", "settings.edit"] as $needed) {
    echo "$needed: " . (hasWildcardPermission($grantedSiti, $needed) ? "diizinkan" : "ditolak") . "\n";
}

Output yang Dihasilkan:

text
posts.create: diizinkan
posts.delete: diizinkan
comments.moderate: diizinkan
settings.edit: ditolak

Logikanya ada di matchPermission(): kesamaan persis langsung lolos; bila yang diberikan berakhiran .*, potong tanda bintangnya lalu periksa apakah permission yang diminta berawalan potongan tersebut. Perhatikan bahwa substr($granted, 0, -1) mempertahankan titik — "posts.*" menjadi "posts." — sehingga postsviewers.delete (tanpa titik) tidak akan tertangkap oleh posts.*. PHP juga menyediakan fnmatch() yang menangani pola serupa:

php
<?php
var_dump(fnmatch("posts.*", "posts.create"));
var_dump(fnmatch("posts.*", "users.view"));

Output yang Dihasilkan:

text
bool(true)
bool(false)

Aturan Deny dan Kompleksitasnya #

Semua model di atas bersifat allow-list: user boleh beraksi karena ada yang mengizinkannya. Kadang kebutuhan datang dari arah sebaliknya — “semua editor boleh menghapus artikel, kecuali Budi yang sedang diinvestigasi”. Pilihan umumnya adalah aturan deny yang selalu menang:

php
<?php
$hakAksesUser = [
    "allow" => ["posts.create", "posts.publish", "posts.delete"],
    "deny"  => ["posts.delete"],
];

function isAllowed(array $hakAkses, string $permission): bool
{
    if (in_array($permission, $hakAkses["deny"], true)) {
        return false; // aturan deny selalu menang
    }

    return in_array($permission, $hakAkses["allow"], true);
}

var_dump(isAllowed($hakAksesUser, "posts.delete"));
var_dump(isAllowed($hakAksesUser, "posts.publish"));

Output yang Dihasilkan:

text
bool(false)
bool(true)

Kompleksitasnya muncul begitu aturan deny bertambah. Ketika satu user mendapat posts.delete dari dua role berbeda, dan salah satunya di-deny, jawaban final bergantung pada urutan pemeriksaan yang harus selalu konsisten. Penyelidikan “mengapa Budi tidak bisa menghapus artikel?” kini menuntut penelusuran dua daftar sekaligus, dan setiap tempat yang lupa memeriksa deny menjadi celah. Karena itu perlakukan deny sebagai pengecualian yang jarang: lebih sehat memotong permission dari bundel role, atau membuat role sempit khusus (“editor-dengan-pembatasan”), daripada menumpuk aturan pengecualian per user.

Pertimbangan saat Aplikasi Tumbuh #

Query userHasPermission() menyalakan tiga JOIN setiap kali dipanggil. Untuk satu-dua pemeriksaan per permintaan ini tidak masalah, tetapi halaman dashboard yang memeriksa belasan permission akan menggandakan query yang sama. Solusi standarnya adalah mengambil seluruh permission user sekali saat login, menyimpannya di session, lalu memeriksa array di memori untuk sisa siklus permintaan — dengan invalidasi saat role user berubah.

Beberapa hal lain yang layak direncanakan sejak awal: audit log yang mencatat siapa mengubah permission siapa, antarmuka admin yang menampilkan matriks role-permission agar perubahan bisa direview, serta disiplin menamai permission secara konsisten (kategori.aksi) agar mudah dicari. Ketika jumlah permission mencapai ratusan, konsistensi penamaan adalah yang membedakan sistem yang masih bisa dipahami dari yang harus ditulis ulang.

Referensi #

Dokumentasi resmi untuk pendalaman lebih lanjut:

Ringkasan #

Permission memindahkan titik keputusan otorisasi dari label peran ke aksi itu sendiri: role menjadi bundel yang mudah disusun ulang, tabel junction menjaga relasinya tetap data biasa, dan wildcard meringkas bundel yang lebar. Kita juga melihat harga yang harus dibayar aturan deny — mudah ditulis, sulit dinalar — serta pentingnya men-cache permission yang sudah diambil. Model ini melengkapi sisi “pengguna manusia” dari otorisasi. Berikutnya kita beralih ke klien yang bukan manusia: skrip dan aplikasi lain yang mengautentikasi diri lewat token API pada chapter API Token Authentication .