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:
| 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 |
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
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:
findByUserAndTenant()membaca tepat satu baris dengan syarat gandauser_id + tenant_id— bentuk query yang sama denganfindForTenant()pada Bagian 2, karena keanggotaan pun identitasnya adalah pasangan, bukan id tunggal.listByTenant()menyaringtenant_id, bukanuser_id: daftar anggota adalah data milik tenant, dan memanggilnya dengan tenant #1 tidak akan pernah menampilkan anggota tenant #2.add()memvalidasi role denganin_array(..., true)sebelum menyentuh database —InvalidArgumentExceptiondengan daftar pilihan yang sah jauh lebih ramah daripadaCHECK constraint failed.remove()membaca keanggotaan target lebih dulu: id yang tidak ada menghasilkanfalse, dan roleownermelemparRuntimeException— aturan domain yang berlaku bahkan bila pemanggil lupa bertanya keAccessControldulu.- 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 menangkapPDOException23000 di pemanggil.
Sekarang walkthrough lengkap operasi keanggotaan dengan data awal Bagian 1 — termasuk tiga penolakan dari tiga lapis aturan yang berbeda:
<?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:
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: 2Rudi 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
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:
- Constructor menyuntikkan
MembershipRepository, bukan PDO —AccessControltidak punya query sendiri, ia hanya konsumen keanggotaan. Komposisi ini membuat keduanya bisa diuji terpisah dan matriks tinggal membaca hasilfindByUserAndTenant(). - Gerbang pertama: keanggotaan.
nulldari repository langsung menjadifalse— 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. - Gerbang kedua:
?? false. Operator null coalescing padaself::MATRIX[$role][$action] ?? falseadalah wujud fisik prinsip deny by default: aksi yang tidak ada di matriks — salah ketikproject.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
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:
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 keanggotaanBlok 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:
aplikasi-saas-multi-tenant/
├── bagian-1-tenant-dan-domain-model/
│ └── schema.sql
└── bagian-3-membership-dan-permissions/
├── src/
│ ├── AccessControl.php
│ └── MembershipRepository.php
└── uji-permissions.phpSimpan salah satu walkthrough di atas sebagai uji-permissions.php di dalam folder bagian-3-membership-dan-permissions, lalu jalankan dari folder tersebut:
php uji-permissions.phpPath __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.phpbaru dengan empat operasi:findByUserAndTenant()sebagai primitif baca,listByTenant()yang ter-scope per tenant,add()dengan validasi role di PHP, danremove()yang melindungi owner lewat aturan domain. - Berkas
src/AccessControl.phpbaru: matriks izinowner/admin/member× delapan aksi, method tunggalcan()dengan dua gerbang (keanggotaan, lalu matriks) dan?? falsesebagai 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 dariTenantContextBagian 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
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
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;
}
}