Bagian 2 menjawab pertanyaan “data milik siapa yang boleh dilihat”; pertanyaan berikutnya lebih halus: “siapa boleh melakukan apa di dalam tenant”. Dewi adalah member di Toko Bunga Melati — bolehkah Dewi membuat proyek di sana? Dewi juga admin di Studio Foto Cendrawasih — bolehkah Dewi mengeluarkan Siti, pemilik tenant itu? Pada bagian ini kita membangun dua class yang menjawab pertanyaan tersebut: MembershipRepository yang mengelola keanggotaan (menambah anggota, mengeluarkan, membaca daftar), dan AccessControl yang menerjemahkan role menjadi keputusan boleh-tidaknya sebuah aksi. Prinsip utamanya tegas: yang tidak eksplisit diizinkan, ditolak.

Role Menentukan Aksi, Bukan Sebaliknya #

Model izin proyek ini adalah role-based access control (RBAC) dalam skop terkecilnya: satu role per pasangan user–tenant, delapan aksi bernama string, dan satu matriks yang memetakan keduanya. Tiga role yang dijamin schema Bagian 1 punya wilayah kekuasaan yang berbeda:

Aksiowneradminmember
project.viewyayaya
project.createyayatidak
project.deleteyayatidak
member.inviteyayatidak
member.removeyayatidak
member.roleyatidaktidak
billing.manageyatidaktidak
tenant.deleteyatidaktidak

Membaca tabel itu dari kiri ke kanan: owner adalah raja di tenantnya, admin mengelola harian tanpa menyentuh struktur dan uang, member adalah pembaca. Dari bawah ke atas: semakin sensitif aksinya, semakin sedikit role yang boleh. Matriks semacam ini juga mudah diaudit — satu pandangan mata sudah menampakkan seluruh kebijakan, tanpa membuka satu method pun. Konsep RBAC yang lebih umum sudah dibahas pada chapter Role-Based Access Control ; di sini kita terapkan dalam skop per-tenant.

Class MembershipRepository #

Repository ini mengelola tabel memberships dengan tiga lapis aturan yang bekerja berlapis: validasi PHP untuk role, aturan domain untuk melindungi owner, dan constraint database untuk keanggotaan ganda. Berikut isi lengkap src/MembershipRepository.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel memberships.
 *
 * Repository ini mengelola keanggotaan user pada tenant: menambah,
 * menghapus, dan membaca. Tiga lapisan aturan bekerja bersama di
 * sini:
 * 1. Validasi PHP (role di luar daftar -> InvalidArgumentException)
 *    agar gagal lebih awal dengan pesan yang jelas.
 * 2. Aturan domain (owner tidak dapat dikeluarkan -> RuntimeException)
 *    yang berlaku bahkan bila pemanggil lupa memeriksa permission.
 * 3. Constraint database (PK komposit menolak keanggotaan ganda ->
 *    PDOException 23000) sebagai pertahanan terakhir.
 */
final class MembershipRepository
{
    /** Daftar role yang diakui schema (CHECK constraint). */
    public const ROLE_VALID = ['owner', 'admin', 'member'];

    public function __construct(private PDO $pdo)
    {
        // PRAGMA foreign_keys berlaku per koneksi; constructor adalah
        // titik yang pasti dilewati setiap kali repository dibuat.
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Ambil satu keanggotaan; null bila user bukan anggota tenant.
     *
     * Method ini adalah primitif yang dipakai AccessControl untuk
     * membaca role seorang user pada satu tenant.
     *
     * @return array<string, mixed>|null baris: user_id, tenant_id, role
     */
    public function findByUserAndTenant(int $userId, int $tenantId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT user_id, tenant_id, role FROM memberships
              WHERE user_id = :user_id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

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

    /**
     * Daftar seluruh anggota satu tenant beserta emailnya.
     *
     * Perhatikan penyaringan: klausa WHERE memakai tenant_id, bukan
     * user_id — daftar anggota adalah data milik tenant, dan query
     * lintas tenant tidak boleh terjadi.
     *
     * @return list<array<string, mixed>> baris: user_id, email, role
     */
    public function listByTenant(int $tenantId): array
    {
        $sql = 'SELECT m.user_id, u.email, m.role
                  FROM memberships m
                  JOIN users u ON u.id = m.user_id
                 WHERE m.tenant_id = :tenant_id
                 ORDER BY u.email';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['tenant_id' => $tenantId]);

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

    /**
     * Tambahkan user ke tenant dengan role tertentu.
     *
     * Role divalidasi di PHP lebih dulu agar pesan galatnya jelas;
     * keanggotaan ganda pada tenant yang sama ditolak database lewat
     * primary key komposit (SQLSTATE 23000) — tangkap PDOException
     * bila perlombaan input membuat pre-check lolos.
     */
    public function add(int $userId, int $tenantId, string $role): void
    {
        if (!in_array($role, self::ROLE_VALID, true)) {
            throw new InvalidArgumentException(
                "Role tidak valid: '$role' (pilihan: " . implode(', ', self::ROLE_VALID) . ')'
            );
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO memberships (user_id, tenant_id, role)
             VALUES (:user_id, :tenant_id, :role)'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId, 'role' => $role]);
    }

    /**
     * Hapus keanggotaan user dari tenant.
     *
     * Mengembalikan false bila keanggotaan tidak ditemukan. Owner
     * TIDAK dapat dihapus dari tenantnya — kepemilikan harus
     * dialihkan lebih dulu (atau tenant dihapus) — karena setiap
     * tenant wajib selalu punya satu pemilik yang dapat diganggu
     * gugat.
     */
    public function remove(int $userId, int $tenantId): bool
    {
        $target = $this->findByUserAndTenant($userId, $tenantId);
        if ($target === null) {
            return false;
        }

        if ($target['role'] === 'owner') {
            throw new RuntimeException(
                "User #$userId adalah owner tenant #$tenantId — alihkan kepemilikan sebelum menghapus keanggotaan"
            );
        }

        $stmt = $this->pdo->prepare(
            'DELETE FROM memberships
              WHERE user_id = :user_id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);

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

Lima hal pada class tersebut perlu dibedah:

  1. findByUserAndTenant() membaca tepat satu baris dengan syarat ganda user_id + tenant_id — bentuk query yang sama dengan findForTenant() pada Bagian 2, karena keanggotaan pun identitasnya adalah pasangan, bukan id tunggal.
  2. listByTenant() menyaring tenant_id, bukan user_id: daftar anggota adalah data milik tenant, dan memanggilnya dengan tenant #1 tidak akan pernah menampilkan anggota tenant #2.
  3. add() memvalidasi role dengan in_array(..., true) sebelum menyentuh database — InvalidArgumentException dengan daftar pilihan yang sah jauh lebih ramah daripada CHECK constraint failed.
  4. remove() membaca keanggotaan target lebih dulu: id yang tidak ada menghasilkan false, dan role owner melempar RuntimeException — aturan domain yang berlaku bahkan bila pemanggil lupa bertanya ke AccessControl dulu.
  5. Keanggotaan ganda tidak dicek di PHP: pre-check findByUserAndTenant() selalu bisa kalah cepat dari input yang berlomba, jadi keputusan akhir kita serahkan pada primary key komposit dan menangkap PDOException 23000 di pemanggil.

Sekarang walkthrough lengkap operasi keanggotaan dengan data awal Bagian 1 — termasuk tiga penolakan dari tiga lapis aturan yang berbeda:

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

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));

$repo = new MembershipRepository($pdo);

echo "Anggota tenant #1 (toko-bunga-melati):\n";
foreach ($repo->listByTenant(1) as $m) {
    echo "  user #{$m['user_id']} {$m['email']} => {$m['role']}\n";
}

echo "\nadd(4, 1, 'member') — Rudi bergabung ke tenant #1\n";
$repo->add(4, 1, 'member');
foreach ($repo->listByTenant(1) as $m) {
    echo "  user #{$m['user_id']} {$m['email']} => {$m['role']}\n";
}

$baru = $repo->findByUserAndTenant(4, 1);
echo "\nfindByUserAndTenant(4, 1): user #{$baru['user_id']}"
    . " di tenant #{$baru['tenant_id']} sebagai {$baru['role']}\n";
echo 'findByUserAndTenant(1, 2) — Budi di tenant #2: ';
var_dump($repo->findByUserAndTenant(1, 2));

echo "\nremove(4, 1): ", ($repo->remove(4, 1) ? 'true' : 'false'), "\n";
echo "remove(4, 1) lagi: ", ($repo->remove(4, 1) ? 'true' : 'false'), "\n";

echo "\nremove(1, 1) — mencoba mengeluarkan owner:\n";
try {
    $repo->remove(1, 1);
    echo "  Terhapus.\n";
} catch (RuntimeException $e) {
    echo "  Ditolak aturan domain: {$e->getMessage()}\n";
}

echo "\nadd(1, 1, 'admin') — keanggotaan ganda:\n";
try {
    $repo->add(1, 1, 'admin');
    echo "  Tersimpan.\n";
} catch (PDOException $e) {
    echo "  Ditolak database [{$e->getCode()}]: {$e->getMessage()}\n";
}

echo "\nadd(4, 1, 'supervisor') — role di luar daftar:\n";
try {
    $repo->add(4, 1, 'supervisor');
    echo "  Tersimpan.\n";
} catch (InvalidArgumentException $e) {
    echo "  Ditolak PHP: {$e->getMessage()}\n";
}

echo "\nJumlah keanggotaan tenant #1 tetap: ", count($repo->listByTenant(1)), "\n";

Output yang Dihasilkan:

text
Anggota tenant #1 (toko-bunga-melati):
  user #1 [email protected] => owner
  user #3 [email protected] => member

add(4, 1, 'member') — Rudi bergabung ke tenant #1
  user #1 [email protected] => owner
  user #3 [email protected] => member
  user #4 [email protected] => member

findByUserAndTenant(4, 1): user #4 di tenant #1 sebagai member
findByUserAndTenant(1, 2) — Budi di tenant #2: NULL

remove(4, 1): true
remove(4, 1) lagi: false

remove(1, 1) — mencoba mengeluarkan owner:
  Ditolak aturan domain: User #1 adalah owner tenant #1 — alihkan kepemilikan sebelum menghapus keanggotaan

add(1, 1, 'admin') — keanggotaan ganda:
  Ditolak database [23000]: SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: memberships.user_id, memberships.tenant_id

add(4, 1, 'supervisor') — role di luar daftar:
  Ditolak PHP: Role tidak valid: 'supervisor' (pilihan: owner, admin, member)

Jumlah keanggotaan tenant #1 tetap: 2

Rudi berhasil bergabung sebagai member lalu keluar bersih: remove() pertama mengembalikan true, dan panggilan kedua false karena keanggotaannya sudah tidak ada — false di sini berarti “tidak ada yang perlu dihapus”, bukan galat. Tiga penolakan di paruh kedua output berasal dari tiga lapis yang berbeda: aturan domain menolak pengeluaran owner, database menolak keanggotaan ganda Budi lewat primary key komposit, dan validasi PHP menolak role supervisor sebelum query dijalankan. Setelah semua percobaan itu, jumlah keanggotaan tenant #1 kembali ke angka 2 — tidak ada data yang berubah oleh operasi yang ditolak.

Class AccessControl #

Matriks pada tabel di awal bagian ini diterjemahkan menjadi class dengan satu method publik. Berikut isi lengkap src/AccessControl.php:

php
<?php

declare(strict_types=1);

/**
 * Pemeriksa izin berbasis role untuk satu tenant.
 *
 * Satu pertanyaan diajukan ke class ini: bolehkah user X melakukan
 * aksi Y pada tenant Z? Jawabannya selalu boolean, dan dua jalur
 * gagal sama-sama mengembalikan false:
 *
 * 1. User bukan anggota tenant tersebut -> false.
 * 2. Aksi tidak dikenal dalam matriks -> false (deny by default).
 *
 * AccessControl TIDAK menulis atau mengubah data; ia hanya membaca
 * role lewat MembershipRepository lalu membaca matriks. Menambah
 * aksi baru berarti menambah satu baris di MATRIX — lupa menambah
 * berarti aksi itu selalu ditolak, bukan selalu diizinkan.
 */
final class AccessControl
{
    /**
     * Matriks izin: role => aksi => boleh?
     *
     * owner  : semua aksi, termasuk mengubah role dan menghapus tenant.
     * admin  : mengelola proyek dan anggota, tanpa hak atas role,
     *          billing, dan penghapusan tenant.
     * member : baca proyek saja (read-only).
     */
    private const MATRIX = [
        'owner' => [
            'project.view'   => true,
            'project.create' => true,
            'project.delete' => true,
            'member.invite'  => true,
            'member.remove'  => true,
            'member.role'    => true,
            'billing.manage' => true,
            'tenant.delete'  => true,
        ],
        'admin' => [
            'project.view'   => true,
            'project.create' => true,
            'project.delete' => true,
            'member.invite'  => true,
            'member.remove'  => true,
            'member.role'    => false,
            'billing.manage' => false,
            'tenant.delete'  => false,
        ],
        'member' => [
            'project.view'   => true,
            'project.create' => false,
            'project.delete' => false,
            'member.invite'  => false,
            'member.remove'  => false,
            'member.role'    => false,
            'billing.manage' => false,
            'tenant.delete'  => false,
        ],
    ];

    public function __construct(private MembershipRepository $memberships)
    {
    }

    /**
     * Bolehkah $userId melakukan $action pada $tenantId?
     *
     * Urutan pemeriksaan: keanggotaan dulu (bukan anggota => false),
     * lalu matriks role (aksi tak dikenal => false). Tidak ada
     * pengecualian di luar dua aturan itu.
     */
    public function can(int $userId, string $action, int $tenantId): bool
    {
        $keanggotaan = $this->memberships->findByUserAndTenant($userId, $tenantId);
        if ($keanggotaan === null) {
            return false;
        }

        return self::MATRIX[$keanggotaan['role']][$action] ?? false;
    }
}

Tiga keputusan pada class tersebut perlu dibedah:

  1. Constructor menyuntikkan MembershipRepository, bukan PDO — AccessControl tidak punya query sendiri, ia hanya konsumen keanggotaan. Komposisi ini membuat keduanya bisa diuji terpisah dan matriks tinggal membaca hasil findByUserAndTenant().
  2. Gerbang pertama: keanggotaan. null dari repository langsung menjadi false — Rudi yang belum jadi anggota, dan Budi yang mencoba bertindak di tenant tetangga, sama-sama ditolak tanpa masuk ke matriks. Isolasi Bagian 2 dan izin Bagian ini bertemu tepat di titik ini.
  3. Gerbang kedua: ?? false. Operator null coalescing pada self::MATRIX[$role][$action] ?? false adalah wujud fisik prinsip deny by default: aksi yang tidak ada di matriks — salah ketik project.creat, aksi masa depan yang belum dipetakan — tidak pernah jatuh ke “diizinkan”.

Walkthrough Matriks Izin #

Walkthrough berikut mencetak matriks lengkap dari sudut pandang pemegang role nyata pada data awal — Budi sebagai owner, Dewi sebagai admin di satu tenant dan member di tenant lain — lalu menguji tiga jalan yang berakhir ditolak, gerbang di handler, dan pertahanan berlapis owner:

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

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));

$repo = new MembershipRepository($pdo);
$access = new AccessControl($repo);

$aksi = [
    'project.view', 'project.create', 'project.delete',
    'member.invite', 'member.remove', 'member.role',
    'billing.manage', 'tenant.delete',
];
$pemegangRole = [
    [1, 1], // Budi, owner tenant #1
    [3, 2], // Dewi, admin tenant #2
    [3, 1], // Dewi, member tenant #1
];

printf("%-18s %-12s %-12s %s\n", 'Aksi', 'owner', 'admin', 'member');
foreach ($aksi as $a) {
    printf(
        "%-18s %-12s %-12s %s\n",
        $a,
        $access->can($pemegangRole[0][0], $a, $pemegangRole[0][1]) ? 'YA' : 'TIDAK',
        $access->can($pemegangRole[1][0], $a, $pemegangRole[1][1]) ? 'YA' : 'TIDAK',
        $access->can($pemegangRole[2][0], $a, $pemegangRole[2][1]) ? 'YA' : 'TIDAK'
    );
}

echo "\nDeny by default — tiga jalan yang semuanya berakhir TIDAK:\n";
$kasus = [
    [1, 'project.export', 1, 'aksi tidak dikenal (owner sekalipun)'],
    [4, 'project.create', 1, 'Rudi bukan anggota tenant #1'],
    [1, 'project.create', 2, 'Budi bukan anggota tenant #2 (lintas tenant)'],
];
foreach ($kasus as [$uid, $aksiDicurigai, $tid, $keterangan]) {
    printf(
        "  can(%d, '%s', %d) => %s — %s\n",
        $uid,
        $aksiDicurigai,
        $tid,
        $access->can($uid, $aksiDicurigai, $tid) ? 'YA' : 'TIDAK',
        $keterangan
    );
}

echo "\nSimulasi gerbang di handler:\n";
if (!$access->can(3, 'project.create', 1)) {
    echo "  Dewi (member) mencoba membuat proyek di tenant #1 => 403 Ditolak\n";
}
if ($access->can(3, 'project.create', 2)) {
    echo "  Dewi (admin) membuat proyek di tenant #2 => diizinkan\n";
}

echo "\nPertahanan berlapis: admin boleh member.remove, owner tetap terlindungi\n";
echo '  can(3, member.remove, 2) => ', $access->can(3, 'member.remove', 2) ? "YA\n" : "TIDAK\n";
try {
    $repo->remove(2, 2);
    echo "  remove(2, 2) terhapus.\n";
} catch (RuntimeException $e) {
    echo "  remove(2, 2) ditolak: {$e->getMessage()}\n";
}

Output yang Dihasilkan:

text
Aksi               owner        admin        member
project.view       YA           YA           YA
project.create     YA           YA           TIDAK
project.delete     YA           YA           TIDAK
member.invite      YA           YA           TIDAK
member.remove      YA           YA           TIDAK
member.role        YA           TIDAK        TIDAK
billing.manage     YA           TIDAK        TIDAK
tenant.delete      YA           TIDAK        TIDAK

Deny by default — tiga jalan yang semuanya berakhir TIDAK:
  can(1, 'project.export', 1) => TIDAK — aksi tidak dikenal (owner sekalipun)
  can(4, 'project.create', 1) => TIDAK — Rudi bukan anggota tenant #1
  can(1, 'project.create', 2) => TIDAK — Budi bukan anggota tenant #2 (lintas tenant)

Simulasi gerbang di handler:
  Dewi (member) mencoba membuat proyek di tenant #1 => 403 Ditolak
  Dewi (admin) membuat proyek di tenant #2 => diizinkan

Pertahanan berlapis: admin boleh member.remove, owner tetap terlindungi
  can(3, member.remove, 2) => YA
  remove(2, 2) ditolak: User #2 adalah owner tenant #2 — alihkan kepemilikan sebelum menghapus keanggotaan

Blok pertama output adalah matriks yang hidup: 24 sel YA/TIDAK (delapan aksi × tiga role) yang persis mengikuti tabel di awal bagian, dihitung dari keanggotaan sungguhan di database. Blok kedua membuktikan deny-by-default dari tiga arah — aksi tak dikenal ditolak meski pemohonnya owner, non-anggota ditolak, dan anggota tenant lain ditolak; perhatikan bahwa can(1, 'project.create', 2) mengembalikan TIDAK bukan karena Budi lemah, tetapi karena keanggotaan Budi memang tidak ada di tenant #2. Blok terakhir menampilkan pertahanan berlapis: AccessControl mengatakan admin boleh mengeluarkan anggota, tetapi remove() tetap menolak saat targetnya owner — permission menjawab “boleh mengeluarkan anggota”, aturan domain menjawab “kecuali pemilik”. Dua lapis ini saling menutup celah: lupa memasang gerbang can() di handler masih tertahan oleh pengecekan di dalam repository.

Cara Menjalankan Contoh #

Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 serta dua berkas di folder src/. Susun foldernya sebagai berikut:

text
aplikasi-saas-multi-tenant/
├── bagian-1-tenant-dan-domain-model/
│   └── schema.sql
└── bagian-3-membership-dan-permissions/
    ├── src/
    │   ├── AccessControl.php
    │   └── MembershipRepository.php
    └── uji-permissions.php

Simpan salah satu walkthrough di atas sebagai uji-permissions.php di dalam folder bagian-3-membership-dan-permissions, lalu jalankan dari folder tersebut:

bash
php uji-permissions.php

Path __DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql' menunjuk folder bagian saudara dari Bagian 1, sehingga struktur folder harus dipertahankan. Seluruh contoh memakai :memory: dan tidak meninggalkan berkas database; TenantContext dari Bagian 2 tidak dipanggil di sini karena AccessControl menerima userId dan tenantId sebagai parameter biasa — pada aplikasi web nyata, kedua nilai itu diambil dari context aktif.

Apa yang Berubah #

Proyek kini memiliki lapisan keanggotaan dan izin yang lengkap:

  • Berkas src/MembershipRepository.php baru dengan empat operasi: findByUserAndTenant() sebagai primitif baca, listByTenant() yang ter-scope per tenant, add() dengan validasi role di PHP, dan remove() yang melindungi owner lewat aturan domain.
  • Berkas src/AccessControl.php baru: matriks izin owner/admin/member × delapan aksi, method tunggal can() dengan dua gerbang (keanggotaan, lalu matriks) dan ?? false sebagai penolakan default.
  • Tiga lapis aturan keanggotaan terbukti bekerja: validasi PHP menolak role asing, database menolak keanggotaan ganda lewat PK komposit, dan aturan domain menolak pengeluaran owner.
  • Permission dan isolasi bertemu: non-anggota dan anggota tenant lain ditolak oleh gerbang pertama can() — sambungan langsung dari TenantContext Bagian 2.

Ringkasan #

MembershipRepository menjadikan keanggotaan sebagai warga kelas satu: bisa ditambah, dibaca, dan dihapus dengan aturan yang jelas, termasuk perlindungan khusus untuk owner. AccessControl menerjemahkan role menjadi keputusan boolean melalui matriks yang mudah diaudit, dan menolak semua yang tidak eksplisit diizinkan. Namun seluruh kemampuan ini masih gratis — belum ada yang membatasi berapa banyak proyek boleh dimiliki atau berapa lama tenant boleh memakai fitur berbayar. Bagian 4 menutupnya dengan dunia billing: PlanRepository, SubscriptionService, siklus berlangganan dengan waktu beku, dan gerbang fitur yang menegakkan batas plan.

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);

/**
 * Pemeriksa izin berbasis role untuk satu tenant.
 *
 * Satu pertanyaan diajukan ke class ini: bolehkah user X melakukan
 * aksi Y pada tenant Z? Jawabannya selalu boolean, dan dua jalur
 * gagal sama-sama mengembalikan false:
 *
 * 1. User bukan anggota tenant tersebut -> false.
 * 2. Aksi tidak dikenal dalam matriks -> false (deny by default).
 *
 * AccessControl TIDAK menulis atau mengubah data; ia hanya membaca
 * role lewat MembershipRepository lalu membaca matriks. Menambah
 * aksi baru berarti menambah satu baris di MATRIX — lupa menambah
 * berarti aksi itu selalu ditolak, bukan selalu diizinkan.
 */
final class AccessControl
{
    /**
     * Matriks izin: role => aksi => boleh?
     *
     * owner  : semua aksi, termasuk mengubah role dan menghapus tenant.
     * admin  : mengelola proyek dan anggota, tanpa hak atas role,
     *          billing, dan penghapusan tenant.
     * member : baca proyek saja (read-only).
     */
    private const MATRIX = [
        'owner' => [
            'project.view'   => true,
            'project.create' => true,
            'project.delete' => true,
            'member.invite'  => true,
            'member.remove'  => true,
            'member.role'    => true,
            'billing.manage' => true,
            'tenant.delete'  => true,
        ],
        'admin' => [
            'project.view'   => true,
            'project.create' => true,
            'project.delete' => true,
            'member.invite'  => true,
            'member.remove'  => true,
            'member.role'    => false,
            'billing.manage' => false,
            'tenant.delete'  => false,
        ],
        'member' => [
            'project.view'   => true,
            'project.create' => false,
            'project.delete' => false,
            'member.invite'  => false,
            'member.remove'  => false,
            'member.role'    => false,
            'billing.manage' => false,
            'tenant.delete'  => false,
        ],
    ];

    public function __construct(private MembershipRepository $memberships)
    {
    }

    /**
     * Bolehkah $userId melakukan $action pada $tenantId?
     *
     * Urutan pemeriksaan: keanggotaan dulu (bukan anggota => false),
     * lalu matriks role (aksi tak dikenal => false). Tidak ada
     * pengecualian di luar dua aturan itu.
     */
    public function can(int $userId, string $action, int $tenantId): bool
    {
        $keanggotaan = $this->memberships->findByUserAndTenant($userId, $tenantId);
        if ($keanggotaan === null) {
            return false;
        }

        return self::MATRIX[$keanggotaan['role']][$action] ?? false;
    }
}

src/MembershipRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel memberships.
 *
 * Repository ini mengelola keanggotaan user pada tenant: menambah,
 * menghapus, dan membaca. Tiga lapisan aturan bekerja bersama di
 * sini:
 * 1. Validasi PHP (role di luar daftar -> InvalidArgumentException)
 *    agar gagal lebih awal dengan pesan yang jelas.
 * 2. Aturan domain (owner tidak dapat dikeluarkan -> RuntimeException)
 *    yang berlaku bahkan bila pemanggil lupa memeriksa permission.
 * 3. Constraint database (PK komposit menolak keanggotaan ganda ->
 *    PDOException 23000) sebagai pertahanan terakhir.
 */
final class MembershipRepository
{
    /** Daftar role yang diakui schema (CHECK constraint). */
    public const ROLE_VALID = ['owner', 'admin', 'member'];

    public function __construct(private PDO $pdo)
    {
        // PRAGMA foreign_keys berlaku per koneksi; constructor adalah
        // titik yang pasti dilewati setiap kali repository dibuat.
        $this->pdo->exec('PRAGMA foreign_keys = ON');
    }

    /**
     * Ambil satu keanggotaan; null bila user bukan anggota tenant.
     *
     * Method ini adalah primitif yang dipakai AccessControl untuk
     * membaca role seorang user pada satu tenant.
     *
     * @return array<string, mixed>|null baris: user_id, tenant_id, role
     */
    public function findByUserAndTenant(int $userId, int $tenantId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT user_id, tenant_id, role FROM memberships
              WHERE user_id = :user_id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

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

    /**
     * Daftar seluruh anggota satu tenant beserta emailnya.
     *
     * Perhatikan penyaringan: klausa WHERE memakai tenant_id, bukan
     * user_id — daftar anggota adalah data milik tenant, dan query
     * lintas tenant tidak boleh terjadi.
     *
     * @return list<array<string, mixed>> baris: user_id, email, role
     */
    public function listByTenant(int $tenantId): array
    {
        $sql = 'SELECT m.user_id, u.email, m.role
                  FROM memberships m
                  JOIN users u ON u.id = m.user_id
                 WHERE m.tenant_id = :tenant_id
                 ORDER BY u.email';

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute(['tenant_id' => $tenantId]);

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

    /**
     * Tambahkan user ke tenant dengan role tertentu.
     *
     * Role divalidasi di PHP lebih dulu agar pesan galatnya jelas;
     * keanggotaan ganda pada tenant yang sama ditolak database lewat
     * primary key komposit (SQLSTATE 23000) — tangkap PDOException
     * bila perlombaan input membuat pre-check lolos.
     */
    public function add(int $userId, int $tenantId, string $role): void
    {
        if (!in_array($role, self::ROLE_VALID, true)) {
            throw new InvalidArgumentException(
                "Role tidak valid: '$role' (pilihan: " . implode(', ', self::ROLE_VALID) . ')'
            );
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO memberships (user_id, tenant_id, role)
             VALUES (:user_id, :tenant_id, :role)'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId, 'role' => $role]);
    }

    /**
     * Hapus keanggotaan user dari tenant.
     *
     * Mengembalikan false bila keanggotaan tidak ditemukan. Owner
     * TIDAK dapat dihapus dari tenantnya — kepemilikan harus
     * dialihkan lebih dulu (atau tenant dihapus) — karena setiap
     * tenant wajib selalu punya satu pemilik yang dapat diganggu
     * gugat.
     */
    public function remove(int $userId, int $tenantId): bool
    {
        $target = $this->findByUserAndTenant($userId, $tenantId);
        if ($target === null) {
            return false;
        }

        if ($target['role'] === 'owner') {
            throw new RuntimeException(
                "User #$userId adalah owner tenant #$tenantId — alihkan kepemilikan sebelum menghapus keanggotaan"
            );
        }

        $stmt = $this->pdo->prepare(
            'DELETE FROM memberships
              WHERE user_id = :user_id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['user_id' => $userId, 'tenant_id' => $tenantId]);

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