Tiga bagian sebelumnya membangun sistem yang memperlakukan semua pengguna setara: sekali login, semua orang melihat hal yang sama. Aplikasi nyata jarang sesederhana itu — ada pengguna biasa, editor yang mengelola konten, dan admin yang mengelola pengguna. Autentikasi menjawab “siapa Anda”; bagian ini menjawab pertanyaan kedua yang sama pentingnya: “apa yang Anda boleh lakukan?” Kita akan menambahkan kolom role lewat migrasi, memusatkan seluruh keputusan akses pada satu matriks izin di class AccessControl, membatasi perubahan peran hanya untuk admin, lalu memasang gerbang route yang menolak permintaan sebelum handler sempat berjalan.

Dari Autentikasi ke Otorisasi #

Pembagian kerja antara autentikasi dan otorisasi sudah kita bahas pada chapter Authentication vs Authorization ; sekarang waktunya mewujudkannya dalam kode. Pola yang kita pakai adalah Role-Based Access Control (RBAC): setiap pengguna memangku tepat satu peran, dan setiap aksi diperiksa terhadap matriks aksi × peran. Mengapa tidak mengecek peran langsung di tiap route dengan if ($user['role'] === 'admin')? Karena keputusan “siapa boleh apa” lalu tersebar ke seluruh kodebase — saat aturan berubah, tidak ada satu tempat yang bisa diaudit. Dengan matriks terpusat, menjawab “siapa saja yang bisa menghapus pengguna?” cukup membaca satu baris array.

Tiga peran dan enam aksi berikut cukup untuk proyek ini, dan cara membacanya berlaku umum untuk sistem berperan apa pun:

Aksiusereditoradmin
profil.lihatyayaya
profil.suntingyayaya
log.audit.lihattidakyaya
pengguna.daftartidaktidakya
pengguna.ubahPerantidaktidakya
pengguna.hapustidaktidakya

Dua aturan main menyertai tabel tersebut. Pertama, penamaan aksi memakai format resource.aksi — cukup satu baris grep untuk menemukan semua pemeriksaan yang menyentuh satu resource. Kedua, dan paling penting: segala pasangan aksi-peran yang tidak tercantum otomatis ditolak. Penolakan sebagai keadaan default (deny by default) berarti aksi baru yang lupa didaftarkan gagal aman, bukan terbuka diam-diam. Materi merancang hubungan peran-izin yang lebih kaya ada pada chapter Permission Modeling .

Menambahkan Kolom role ke Tabel users #

Skema Bagian 1 tidak mengenal peran, dan memang seharusnya tidak berubah: bagian-bagian lama harus tetap berjalan persis seperti saat ditulis. Kolom baru datang lewat migrasi — pernyataan ALTER TABLE yang dikenal semua database sungguhan:

sql
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';

Tiga keputusan kecil pada satu baris tersebut. NOT NULL dengan DEFAULT 'user' membuat kolom terisi untuk baris lama sekaligus baris baru — SQLite menolak ADD COLUMN NOT NULL tanpa default justru untuk melindungi data lama dari nilai kosong. Nilai default 'user' menunjuk peran paling rendah: akun yang sudah ada tidak boleh tiba-tiba memperoleh hak baru hanya karena sistem belajar mengenal peran. Terakhir, kolom ini tidak memiliki constraint CHECK daftar peran — validasi nama peran kita lakukan di AccessControl, satu tempat bersama seluruh logika izin.

Ada satu jebakan operasional: ALTER TABLE ... ADD COLUMN tidak idempoten. Menjalankannya dua kali menghasilkan galat duplicate column name: role, sehingga migrasi seperti ini tidak bisa ditempel begitu saja di awal setiap skrip. Penjagaannya adalah memeriksa struktur tabel lebih dulu lewat PRAGMA table_info, persis seperti demo pada Bagian 1 — method pasangKolomPeran() di class kita mengerjakannya.

Class AccessControl #

Berikut isi lengkap src/AccessControl.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:

php
<?php

declare(strict_types=1);

/**
 * Exception untuk permintaan yang tidak berhak: pengguna tidak dikenal,
 * atau perannya tidak memenuhi syarat gerbang.
 */
final class UnauthorizedException extends RuntimeException
{
}

/**
 * Kontrol akses berbasis peran (RBAC) untuk sistem autentikasi.
 *
 * Tiga peran dikenal: user, editor, admin. Hak akses tidak pernah
 * ditulis sebagai if bertebaran di tengah kode aplikasi; semuanya
 * dipusatkan pada matriks izin di class ini. Route dan service cukup
 * bertanya lewat can(), atau memasang gerbang lewat requireRole().
 */
final class AccessControl
{
    /** Peran yang dikenal sistem, dari yang paling rendah. */
    public const PERAN_VALID = ['user', 'editor', 'admin'];

    /**
     * Matriks izin: aksi x peran -> boleh atau tidak.
     *
     * Pasangan aksi-peran yang tidak tercantum di sini otomatis
     * bernilai false - termasuk aksi yang salah ketik dan peran
     * yang tidak dikenal. Penolakan adalah keadaan default.
     */
    private const MATRIKS_IZIN = [
        'profil.lihat'       => ['user' => true, 'editor' => true, 'admin' => true],
        'profil.sunting'     => ['user' => true, 'editor' => true, 'admin' => true],
        'log.audit.lihat'    => ['user' => false, 'editor' => true, 'admin' => true],
        'pengguna.daftar'    => ['user' => false, 'editor' => false, 'admin' => true],
        'pengguna.ubahPeran' => ['user' => false, 'editor' => false, 'admin' => true],
        'pengguna.hapus'     => ['user' => false, 'editor' => false, 'admin' => true],
    ];

    public function __construct(private PDO $pdo)
    {
    }

    /**
     * Pasang kolom role pada tabel users bila belum ada.
     *
     * ALTER TABLE ... ADD COLUMN tidak idempoten: pernyataan yang sama
     * gagal dengan "duplicate column name" bila dijalankan dua kali.
     * Method ini menjaganya dengan memeriksa PRAGMA table_info lebih
     * dulu, sehingga aman dipanggil setiap kali aplikasi bersiap.
     */
    public static function pasangKolomPeran(PDO $pdo): void
    {
        if (self::kolomAda($pdo, 'users', 'role')) {
            return;
        }

        $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
    }

    /**
     * Bolehkah subjek melakukan satu aksi?
     *
     * Parameter pertama menerima id pengguna (int) atau nama peran
     * (string). Versi int membaca peran pengguna dari database;
     * versi string memeriksa matriks secara langsung - berguna
     * untuk menguji matriks itu sendiri tanpa menyentuh database.
     */
    public function can(string|int $userIdAtauPeran, string $aksi): bool
    {
        $peran = is_int($userIdAtauPeran)
            ? $this->peranPengguna($userIdAtauPeran)
            : $userIdAtauPeran;

        return self::MATRIKS_IZIN[$aksi][$peran] ?? false;
    }

    /**
     * Gerbang untuk route: lempar UnauthorizedException bila peran
     * pengguna tidak termasuk daftar yang diizinkan.
     *
     * Dipanggil tanpa argumen peran, method ini hanya memastikan
     * penggunanya dikenal - gerbang "wajib login". Bila pengecekan
     * yang dibutuhkan berbasis aksi, gunakan can() agar matriks
     * tetap menjadi satu-satunya sumber kebenaran.
     */
    public function requireRole(int $userId, string ...$peranDiizinkan): void
    {
        $peran = $this->peranPengguna($userId);

        $cukup = $peran !== null
            && ($peranDiizinkan === [] || in_array($peran, $peranDiizinkan, true));

        if (!$cukup) {
            throw new UnauthorizedException('Akses ditolak: peran tidak mencukupi.');
        }
    }

    /**
     * Ubah peran seorang pengguna - hanya admin yang boleh.
     *
     * Pemeriksaan hak aktor dilakukan paling awal: penyerang yang
     * tidak berhak tidak perlu tahu apakah peran yang dimintanya
     * valid atau apakah targetnya ada, karena keduanya baru
     * diperiksa setelah hak aktor lolos.
     *
     * @return array<string, mixed> baris pengguna setelah diperbarui
     *
     * @throws UnauthorizedException bila aktor bukan admin
     * @throws InvalidArgumentException bila nama peran tidak dikenal
     * @throws RuntimeException bila target tidak ditemukan
     */
    public function ubahPeran(int $idAktor, int $idTarget, string $peranBaru): array
    {
        if (!$this->can($idAktor, 'pengguna.ubahPeran')) {
            throw new UnauthorizedException('Hanya admin yang boleh mengubah peran pengguna.');
        }

        if (!in_array($peranBaru, self::PERAN_VALID, true)) {
            throw new InvalidArgumentException(
                "Peran tidak dikenal: $peranBaru. Yang valid: user, editor, admin."
            );
        }

        if ($this->cariRingkas($idTarget) === null) {
            throw new RuntimeException("Pengguna dengan id $idTarget tidak ditemukan.");
        }

        $stmt = $this->pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
        $stmt->execute([$peranBaru, $idTarget]);

        return $this->cariRingkas($idTarget);
    }

    /**
     * Peran seorang pengguna; null bila id tidak ditemukan.
     *
     * Query ini hidup di sini, bukan di UserRepository Bagian 2,
     * karena kolom role lahir setelah class tersebut selesai -
     * daftar kolomnya tidak menyebut role dan tidak perlu diubah.
     */
    private function peranPengguna(int $userId): ?string
    {
        $stmt = $this->pdo->prepare('SELECT role FROM users WHERE id = ?');
        $stmt->execute([$userId]);
        $peran = $stmt->fetchColumn();

        return $peran === false ? null : (string) $peran;
    }

    /**
     * Baris ringkas pengguna: id, email, nama, role.
     *
     * @return array<string, mixed>|null
     */
    private function cariRingkas(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, nama, role FROM users WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Apakah sebuah kolom sudah ada pada tabel? PRAGMA table_info
     * tidak menerima parameter bound, jadi nama tabel diinterpolasi
     * langsung - aman karena berasal dari kode, bukan masukan.
     */
    private static function kolomAda(PDO $pdo, string $tabel, string $kolom): bool
    {
        $baris = $pdo->query('PRAGMA table_info(' . $tabel . ')')
                     ->fetchAll(PDO::FETCH_ASSOC);

        return in_array($kolom, array_column($baris, 'name'), true);
    }
}

Keputusan-keputusan penting pada class tersebut perlu dibedah satu per satu:

  1. pasangKolomPeran() bersifat static karena tugasnya adalah persiapan database, bukan perilaku objek — ia dipanggil sekali saat bootstrap, sebelum service mana pun bekerja. Pemeriksaan kolomAda() lebih dulu membuat migrasi ini idempoten, berbeda dengan ALTER TABLE mentah.
  2. can() menerima union type string|int. Versi string (can('editor', ...)) memeriksa matriks tanpa menyentuh database — berguna menguji matriks itu sendiri; versi int (can($userId, ...)) membaca peran pengguna dari tabel. Ekspresi self::MATRIKS_IZIN[$aksi][$peran] ?? false adalah wujud deny-by-default: pasangan yang tidak ada, aksi yang salah ketik, peran asing, maupun id pengguna yang tidak dikenal semuanya jatuh ke false tanpa memicu warning.
  3. requireRole() menerima daftar peran variadik. Dipanggil tanpa argumen peran, method ini menjadi gerbang “wajib login”; dengan argumen, ia menolak semua peran di luar daftar. Ia tidak mengembalikan boolean, melainkan melempar UnauthorizedException — gerbang yang gagal memutus alur lewat exception, bukan menyuruh pemanggil menyusun if sendiri di setiap route.
  4. ubahPeran() memeriksa hak aktor paling awal, bahkan sebelum memvalidasi nama peran. Penyerang yang bukan admin tidak pernah belajar apakah superuser itu peran yang sah atau apakah id 999 ada — informasi yang baru diberikan setelah hak aktor lolos.
  5. Query peran hidup di class ini, bukan di UserRepository. Daftar kolom pada cariEmail()/cariId() Bagian 2 memang tidak menyebut role karena kolom itu belum ada saat class tersebut ditulis; kita tidak mengubah kode lama yang sudah teruji, melainkan menempatkan query baru di pemilik logikanya.

Walkthrough Migrasi dan Admin Pertama #

Saatnya membuktikan seluruh cerita migrasi di atas. Skrip berikut memuat schema.sql, membuktikan bahwa ALTER TABLE mentah menolak dijalankan dua kali, lalu memperlihatkan versi berjaga dari pasangKolomPeran():

php
<?php
require __DIR__ . '/src/AccessControl.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));

// ALTER mentah: sekali berhasil...
$pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");

// ...tetapi tidak idempoten - pemanggilan kedua gagal.
try {
    $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
    echo "ALTER kedua berhasil (seharusnya tidak)\n";
} catch (PDOException $e) {
    echo "ALTER kedua ditolak [{$e->getCode()}]\n";
    echo "  {$e->getMessage()}\n";
}

// Versi berjaga: pasangKolomPeran() aman dipanggil berkali-kali.
AccessControl::pasangKolomPeran($pdo);
AccessControl::pasangKolomPeran($pdo);

$kolom = array_column($pdo->query('PRAGMA table_info(users)')->fetchAll(PDO::FETCH_ASSOC), 'name');
echo "\nKolom users setelah migrasi: " . implode(', ', $kolom) . "\n";

echo "Peran awal para pengguna:\n";
foreach ($pdo->query('SELECT id, email, role FROM users ORDER BY id') as $u) {
    echo "  #{$u['id']} {$u['email']} -> {$u['role']}\n";
}

// Admin pertama tidak bisa lahir dari izin: siapa pun yang berhak
// mengangkat admin pasti admin juga. Penyelamatan datang dari satu
// UPDATE langsung di luar sistem izin - sekali saja, saat pemasangan.
$pdo->exec("UPDATE users SET role = 'admin' WHERE email = '[email protected]'");

$ac = new AccessControl($pdo);

echo "\nSetelah bootstrap admin pertama:\n";
echo "  can(budi, 'pengguna.ubahPeran') : " . var_export($ac->can(1, 'pengguna.ubahPeran'), true) . "\n";
echo "  can(siti, 'pengguna.ubahPeran') : " . var_export($ac->can(2, 'pengguna.ubahPeran'), true) . "\n";

Output yang Dihasilkan:

text
ALTER kedua ditolak [HY000]
  SQLSTATE[HY000]: General error: 1 duplicate column name: role

Kolom users setelah migrasi: id, email, password_hash, nama, email_verified_at, dibuat_pada, role
Peran awal para pengguna:
  #1 [email protected] -> user
  #2 [email protected] -> user
  #3 [email protected] -> user

Setelah bootstrap admin pertama:
  can(budi, 'pengguna.ubahPeran') : true
  can(siti, 'pengguna.ubahPeran') : false

Galat duplicate column name pada awal output adalah bukti mengapa guard PRAGMA table_info wajib ada: dua pemanggilan pasangKolomPeran() setelahnya lolos tanpa galat karena pemanggilan kedua menemukan kolom sudah terpasang. Daftar kolom menunjukkan role menempel di posisi terakhir tanpa mengubah enam kolom lama, dan ketiga akun seed otomatis memangku peran user — itulah kerja DEFAULT. Terakhir, paruh bawah skrip memecahkan teka-teki chicken-and-egg admin pertama: sistem izin sendiri tidak bisa mengangkat admin pertamanya, karena mengangkat admin membutuhkan admin. Satu UPDATE langsung di luar sistem izin — dijalankan sekali oleh orang yang memasang aplikasi, sama seperti proses pembuatan akun root saat instalasi sistem operasi.

Matriks Izin sebagai Satu-satunya Sumber Kebenaran #

Dengan peran terpasang, kita uji matriksnya dari dua arah: isi tabel untuk tiga peran yang sah, lalu perilaku deny-by-default untuk segala sesuatu di luar tabel:

php
<?php
require __DIR__ . '/src/AccessControl.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
AccessControl::pasangKolomPeran($pdo);

$ac = new AccessControl($pdo);

$aksiSemua = [
    'profil.lihat',
    'profil.sunting',
    'log.audit.lihat',
    'pengguna.daftar',
    'pengguna.ubahPeran',
    'pengguna.hapus',
];

echo "aksi                 user   editor admin\n";
foreach ($aksiSemua as $aksi) {
    printf(
        "%-20s %-6s %-6s %s\n",
        $aksi,
        $ac->can('user', $aksi) ? 'ya' : 'tidak',
        $ac->can('editor', $aksi) ? 'ya' : 'tidak',
        $ac->can('admin', $aksi) ? 'ya' : 'tidak'
    );
}

echo "\nSegala sesuatu di luar matriks ditolak:\n";
echo '  can(admin, aksi yang tidak terdaftar) : '
    . var_export($ac->can('admin', 'pengguna.hapusSemua'), true) . "\n";
echo '  can(peran yang tidak dikenal)         : '
    . var_export($ac->can('superuser', 'pengguna.hapus'), true) . "\n";
echo '  can(pengguna yang tidak dikenal)      : '
    . var_export($ac->can(999, 'pengguna.hapus'), true) . "\n";

echo "\nSebelum ada perubahan, semua akun seed memangku peran user:\n";
echo '  can(siti, log.audit.lihat)   : ' . var_export($ac->can(2, 'log.audit.lihat'), true) . "\n";
echo '  can(dewi, pengguna.daftar)   : ' . var_export($ac->can(3, 'pengguna.daftar'), true) . "\n";

Output yang Dihasilkan:

text
aksi                 user   editor admin
profil.lihat         ya     ya     ya
profil.sunting       ya     ya     ya
log.audit.lihat      tidak  ya     ya
pengguna.daftar      tidak  tidak  ya
pengguna.ubahPeran   tidak  tidak  ya
pengguna.hapus       tidak  tidak  ya

Segala sesuatu di luar matriks ditolak:
  can(admin, aksi yang tidak terdaftar) : false
  can(peran yang tidak dikenal)         : false
  can(pengguna yang tidak dikenal)      : false

Sebelum ada perubahan, semua akun seed memangku peran user:
  can(siti, log.audit.lihat)   : false
  can(dewi, pengguna.daftar)   : false

Enam baris pertama output adalah tabel matriks itu sendiri, dibangun dari panggilan can() versi string — bukan hardcopy teks. Tiga baris berikutnya membuktikan deny-by-default dari tiga arah serangan yang berbeda: aksi yang tidak terdaftar, peran yang tidak dikenal, dan id pengguna yang tidak ada; semuanya false. Dua baris terakhir menegaskan efek DEFAULT 'user': sebelum ada perubahan, Siti dan Dewi tertolak untuk aksi di atas peran mereka meski keduanya adalah akun sah.

Mengubah Peran: Hanya Admin #

Sekarang alur perubahan peran yang sesungguhnya. Admin Budi mengangkat Dewi menjadi editor, lalu tiga percobaan penyalahgunaan dijalankan terhadap sistem:

php
<?php
require __DIR__ . '/src/AccessControl.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
AccessControl::pasangKolomPeran($pdo);

// Bootstrap admin pertama: satu UPDATE langsung di luar sistem izin.
$pdo->exec("UPDATE users SET role = 'admin' WHERE email = '[email protected]'");

$ac = new AccessControl($pdo);

// Alur sah: admin mengangkat Dewi menjadi editor.
$hasil = $ac->ubahPeran(1, 3, 'editor');
echo "Promosi sukses: {$hasil['email']} kini berperan {$hasil['role']}\n";
echo '  can(dewi, log.audit.lihat) : ' . var_export($ac->can(3, 'log.audit.lihat'), true) . "\n";
echo '  can(dewi, pengguna.hapus)  : ' . var_export($ac->can(3, 'pengguna.hapus'), true) . "\n";

// Siti (user) mencoba mengangkat Dewi menjadi admin.
try {
    $ac->ubahPeran(2, 3, 'admin');
    echo "Promosi oleh user berhasil (seharusnya tidak)\n";
} catch (UnauthorizedException $e) {
    echo "\nPromosi oleh user ditolak: {$e->getMessage()}\n";
}

// Dewi (editor) mencoba melakukannya juga.
try {
    $ac->ubahPeran(3, 2, 'admin');
    echo "Promosi oleh editor berhasil (seharusnya tidak)\n";
} catch (UnauthorizedException $e) {
    echo "Promosi oleh editor ditolak: {$e->getMessage()}\n";
}

// Admin memakai nama peran yang tidak dikenal.
try {
    $ac->ubahPeran(1, 2, 'superuser');
} catch (InvalidArgumentException $e) {
    echo "\nPeran tak dikenal ditolak: {$e->getMessage()}\n";
}

// Admin menunjuk pengguna yang tidak ada.
try {
    $ac->ubahPeran(1, 999, 'editor');
} catch (RuntimeException $e) {
    echo "Target tak dikenal ditolak: {$e->getMessage()}\n";
}

echo "\nKeadaan akhir peran:\n";
foreach ($pdo->query('SELECT email, role FROM users ORDER BY id') as $u) {
    echo "  {$u['email']} -> {$u['role']}\n";
}

Output yang Dihasilkan:

text
Promosi sukses: [email protected] kini berperan editor
  can(dewi, log.audit.lihat) : true
  can(dewi, pengguna.hapus)  : false

Promosi oleh user ditolak: Hanya admin yang boleh mengubah peran pengguna.
Promosi oleh editor ditolak: Hanya admin yang boleh mengubah peran pengguna.

Peran tak dikenal ditolak: Peran tidak dikenal: superuser. Yang valid: user, editor, admin.
Target tak dikenal ditolak: Pengguna dengan id 999 tidak ditemukan.

Keadaan akhir peran:
  [email protected] -> admin
  [email protected] -> user
  [email protected] -> editor

Promosi yang sah segera terasa di matriks: Dewi kini boleh membaca log audit, tetapi tetap tertolak menghapus pengguna — peran editor tidak mewarisi seluruh hak admin. Dua percobaan pembajakan berikut ditolak dengan UnauthorizedException baik oleh user maupun editor, dan pesannya sama persis: penolakan tidak membocorkan apakah targetnya valid. Perhatikan bahwa percobaan Siti memakai target Dewi yang nyata — penolakan terjadi karena hak aktor, bukan karena target tidak ada. Baris keadaan akhir menegaskan tidak ada perubahan yang lolos diam-diam: hanya Dewi yang berpindah peran.

Gerbang Route dengan requireRole #

Puncak bagian ini adalah pemakaian di tempat yang paling terasa: front controller. Fungsi tanganiRoute() berikut memasang gerbang sebelum handler route dijalankan — pola yang di framework nyata dikerjakan middleware. Tiga route dijaga dengan tiga ketegasan berbeda: cukup login, editor atau admin, dan admin saja:

php
<?php
require __DIR__ . '/src/AccessControl.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-user-schema/schema.sql'));
AccessControl::pasangKolomPeran($pdo);

$pdo->exec("UPDATE users SET role = 'admin' WHERE email = '[email protected]'");

$ac = new AccessControl($pdo);
$ac->ubahPeran(1, 3, 'editor');

/**
 * Front controller mini: gerbang dipasang SEBELUM handler route
 * dijalankan, meniru middleware pada framework web.
 */
function tanganiRoute(string $rute, ?int $userId, AccessControl $ac): string
{
    if ($userId === null) {
        return '401 Login diperlukan';
    }

    try {
        match ($rute) {
            '/profil'    => $ac->requireRole($userId),
            '/log-audit' => $ac->requireRole($userId, 'editor', 'admin'),
            '/pengguna'  => $ac->requireRole($userId, 'admin'),
            default      => $ac->requireRole($userId, 'admin'),
        };
    } catch (UnauthorizedException $e) {
        return '403 ' . $e->getMessage();
    }

    return "200 OK ($rute)";
}

$pemohon = [
    'tamu (belum login)' => null,
    'siti (user)'        => 2,
    'dewi (editor)'      => 3,
    'budi (admin)'       => 1,
];

foreach (['/profil', '/log-audit', '/pengguna'] as $rute) {
    echo "== GET $rute ==\n";
    foreach ($pemohon as $label => $userId) {
        $status = tanganiRoute($rute, $userId, $ac);
        echo '  ' . str_pad($label, 19) . " -> $status\n";
    }
}

Output yang Dihasilkan:

text
== GET /profil ==
  tamu (belum login)  -> 401 Login diperlukan
  siti (user)         -> 200 OK (/profil)
  dewi (editor)       -> 200 OK (/profil)
  budi (admin)        -> 200 OK (/profil)
== GET /log-audit ==
  tamu (belum login)  -> 401 Login diperlukan
  siti (user)         -> 403 Akses ditolak: peran tidak mencukupi.
  dewi (editor)       -> 200 OK (/log-audit)
  budi (admin)        -> 200 OK (/log-audit)
== GET /pengguna ==
  tamu (belum login)  -> 401 Login diperlukan
  siti (user)         -> 403 Akses ditolak: peran tidak mencukupi.
  dewi (editor)       -> 403 Akses ditolak: peran tidak mencukupi.
  budi (admin)        -> 200 OK (/pengguna)

Baca output per kolom route. /profil menerima siapa pun yang dikenal dan menolak hanya tamu — itulah requireRole($userId) tanpa argumen peran. /log-audit memangkas baris user dengan 403, tetapi menerima editor dan admin. /pengguna paling ketat: hanya admin yang lolos. Ekspresi match memetakan route ke gerbangnya dalam satu tabel ringkas, dan catch (UnauthorizedException) menerjemahkan exception menjadi status 403 di satu tempat — handler route tidak pernah tahu ada penolakan; handler hanya berjalan bila gerbang lolos. Pemisahan ini persis pola middleware pada framework seperti Laravel dan Symfony, dibahas tuntas pada chapter Role-Based Access Control .

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 dan src/AccessControl.php dari bagian ini. Susun foldernya sebagai berikut:

text
sistem-autentikasi/
├── bagian-1-user-schema/
│   └── schema.sql
├── bagian-2-registrasi-dan-password-hashing/
│   └── src/
│       ├── UserRepository.php
│       └── RegistrationService.php
├── bagian-3-login-dan-session/
│   └── src/
│       └── AuthService.php
└── bagian-4-role-dan-permissions/
    ├── src/
    │   └── AccessControl.php
    └── uji-rbac.php

Simpan salah satu walkthrough di atas sebagai uji-rbac.php di dalam folder bagian-4-role-dan-permissions, lalu jalankan:

bash
php uji-rbac.php

Seluruh contoh memakai :memory: dan memanggil pasangKolomPeran() sendiri, sehingga tidak ada berkas database yang tersisa dan hasilnya identik berapa pun kali dijalankan. Pada aplikasi web sungguhan, pasangKolomPeran() setara dengan berkas migrasi yang dijalankan sekali oleh alat migrasi, sedangkan pemasangan gerbang di tanganiRoute() pindah ke middleware yang menerima setiap request.

Apa yang Berubah #

Dibandingkan Bagian 3 yang mengenal semua pengguna sebagai rata, sistem kini mengenal tingkatan:

  • Berkas src/AccessControl.php baru berisi AccessControl beserta UnauthorizedException: matriks izin enam aksi × tiga peran dengan deny-by-default, can() versi id dan versi peran, requireRole() variadik untuk gerbang route, dan ubahPeran() yang hanya bisa dijalankan admin.
  • Tabel users mendapat kolom role lewat migrasi ALTER TABLE yang dijaga pemeriksaan PRAGMA table_info; akun lama otomatis memangku peran user dan tidak ada kode Bagian 1-3 yang berubah.
  • Perubahan peran kini punya satu pintu bernama ubahPeran(): user dan editor tertolak dengan pesan yang sama, nama peran tak dikenal dan target tak dikenal baru diperiksa setelah hak aktor lolos.
  • Gerbang route terbukti bekerja dari empat posisi (tamu, user, editor, admin) pada tiga route dengan ketegasan berbeda — pola yang tinggal dipindahkan ke middleware pada framework.

Ringkasan #

Otorisasi yang aman bermula dari satu tabel kecil: aksi, peran, dan boolean. Dengan menempatkan matriks itu sebagai satu-satunya sumber kebenaran, setiap pertanyaan “boleh atau tidak” punya satu jawaban yang bisa diaudit, deny-by-default membuat aksi baru gagal aman, dan penyalahgunaan perubahan peran terputus di satu method yang hanya mempercayai admin. Fondasi autentikasi Bagian 1-3 kini berlapis kendali akses. Namun satu pintu masuk masih rawan: kata sandi yang dilupakan. Bagian 5 menutupnya dengan alur reset kata sandi berbasis token sekali pakai.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

src/AccessControl.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Exception untuk permintaan yang tidak berhak: pengguna tidak dikenal,
 * atau perannya tidak memenuhi syarat gerbang.
 */
final class UnauthorizedException extends RuntimeException
{
}

/**
 * Kontrol akses berbasis peran (RBAC) untuk sistem autentikasi.
 *
 * Tiga peran dikenal: user, editor, admin. Hak akses tidak pernah
 * ditulis sebagai if bertebaran di tengah kode aplikasi; semuanya
 * dipusatkan pada matriks izin di class ini. Route dan service cukup
 * bertanya lewat can(), atau memasang gerbang lewat requireRole().
 */
final class AccessControl
{
    /** Peran yang dikenal sistem, dari yang paling rendah. */
    public const PERAN_VALID = ['user', 'editor', 'admin'];

    /**
     * Matriks izin: aksi x peran -> boleh atau tidak.
     *
     * Pasangan aksi-peran yang tidak tercantum di sini otomatis
     * bernilai false - termasuk aksi yang salah ketik dan peran
     * yang tidak dikenal. Penolakan adalah keadaan default.
     */
    private const MATRIKS_IZIN = [
        'profil.lihat'       => ['user' => true, 'editor' => true, 'admin' => true],
        'profil.sunting'     => ['user' => true, 'editor' => true, 'admin' => true],
        'log.audit.lihat'    => ['user' => false, 'editor' => true, 'admin' => true],
        'pengguna.daftar'    => ['user' => false, 'editor' => false, 'admin' => true],
        'pengguna.ubahPeran' => ['user' => false, 'editor' => false, 'admin' => true],
        'pengguna.hapus'     => ['user' => false, 'editor' => false, 'admin' => true],
    ];

    public function __construct(private PDO $pdo)
    {
    }

    /**
     * Pasang kolom role pada tabel users bila belum ada.
     *
     * ALTER TABLE ... ADD COLUMN tidak idempoten: pernyataan yang sama
     * gagal dengan "duplicate column name" bila dijalankan dua kali.
     * Method ini menjaganya dengan memeriksa PRAGMA table_info lebih
     * dulu, sehingga aman dipanggil setiap kali aplikasi bersiap.
     */
    public static function pasangKolomPeran(PDO $pdo): void
    {
        if (self::kolomAda($pdo, 'users', 'role')) {
            return;
        }

        $pdo->exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
    }

    /**
     * Bolehkah subjek melakukan satu aksi?
     *
     * Parameter pertama menerima id pengguna (int) atau nama peran
     * (string). Versi int membaca peran pengguna dari database;
     * versi string memeriksa matriks secara langsung - berguna
     * untuk menguji matriks itu sendiri tanpa menyentuh database.
     */
    public function can(string|int $userIdAtauPeran, string $aksi): bool
    {
        $peran = is_int($userIdAtauPeran)
            ? $this->peranPengguna($userIdAtauPeran)
            : $userIdAtauPeran;

        return self::MATRIKS_IZIN[$aksi][$peran] ?? false;
    }

    /**
     * Gerbang untuk route: lempar UnauthorizedException bila peran
     * pengguna tidak termasuk daftar yang diizinkan.
     *
     * Dipanggil tanpa argumen peran, method ini hanya memastikan
     * penggunanya dikenal - gerbang "wajib login". Bila pengecekan
     * yang dibutuhkan berbasis aksi, gunakan can() agar matriks
     * tetap menjadi satu-satunya sumber kebenaran.
     */
    public function requireRole(int $userId, string ...$peranDiizinkan): void
    {
        $peran = $this->peranPengguna($userId);

        $cukup = $peran !== null
            && ($peranDiizinkan === [] || in_array($peran, $peranDiizinkan, true));

        if (!$cukup) {
            throw new UnauthorizedException('Akses ditolak: peran tidak mencukupi.');
        }
    }

    /**
     * Ubah peran seorang pengguna - hanya admin yang boleh.
     *
     * Pemeriksaan hak aktor dilakukan paling awal: penyerang yang
     * tidak berhak tidak perlu tahu apakah peran yang dimintanya
     * valid atau apakah targetnya ada, karena keduanya baru
     * diperiksa setelah hak aktor lolos.
     *
     * @return array<string, mixed> baris pengguna setelah diperbarui
     *
     * @throws UnauthorizedException bila aktor bukan admin
     * @throws InvalidArgumentException bila nama peran tidak dikenal
     * @throws RuntimeException bila target tidak ditemukan
     */
    public function ubahPeran(int $idAktor, int $idTarget, string $peranBaru): array
    {
        if (!$this->can($idAktor, 'pengguna.ubahPeran')) {
            throw new UnauthorizedException('Hanya admin yang boleh mengubah peran pengguna.');
        }

        if (!in_array($peranBaru, self::PERAN_VALID, true)) {
            throw new InvalidArgumentException(
                "Peran tidak dikenal: $peranBaru. Yang valid: user, editor, admin."
            );
        }

        if ($this->cariRingkas($idTarget) === null) {
            throw new RuntimeException("Pengguna dengan id $idTarget tidak ditemukan.");
        }

        $stmt = $this->pdo->prepare('UPDATE users SET role = ? WHERE id = ?');
        $stmt->execute([$peranBaru, $idTarget]);

        return $this->cariRingkas($idTarget);
    }

    /**
     * Peran seorang pengguna; null bila id tidak ditemukan.
     *
     * Query ini hidup di sini, bukan di UserRepository Bagian 2,
     * karena kolom role lahir setelah class tersebut selesai -
     * daftar kolomnya tidak menyebut role dan tidak perlu diubah.
     */
    private function peranPengguna(int $userId): ?string
    {
        $stmt = $this->pdo->prepare('SELECT role FROM users WHERE id = ?');
        $stmt->execute([$userId]);
        $peran = $stmt->fetchColumn();

        return $peran === false ? null : (string) $peran;
    }

    /**
     * Baris ringkas pengguna: id, email, nama, role.
     *
     * @return array<string, mixed>|null
     */
    private function cariRingkas(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, email, nama, role FROM users WHERE id = ?'
        );
        $stmt->execute([$id]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        return $baris === false ? null : $baris;
    }

    /**
     * Apakah sebuah kolom sudah ada pada tabel? PRAGMA table_info
     * tidak menerima parameter bound, jadi nama tabel diinterpolasi
     * langsung - aman karena berasal dari kode, bukan masukan.
     */
    private static function kolomAda(PDO $pdo, string $tabel, string $kolom): bool
    {
        $baris = $pdo->query('PRAGMA table_info(' . $tabel . ')')
                     ->fetchAll(PDO::FETCH_ASSOC);

        return in_array($kolom, array_column($baris, 'name'), true);
    }
}