Bagian 1 sudah melabeli kepemilikan data dengan kolom tenant_id, tetapi label belum berarti isolasi: satu query yang lupa menyaring tenant_id langsung menampilkan proyek Studio Foto Cendrawasih di layar Toko Bunga Melati. Pada bagian ini kita membangun pertahanan pertama di lapisan kode: TenantContext, objek immutable yang menjawab “user siapa sedang bekerja di tenant mana dengan role apa”, dan TenantRepository yang membaca daftar tenant seorang user lewat tabel memberships. Kita menutup dengan walkthrough yang membuktikan isolasi benar-benar terjadi — daftar proyek menyusut mengikuti konteks, dan akses lintas tenant lewat id proyek ditolak.

Query yang Lupa Konteks adalah Kebocoran Data #

Dua bug paling umum pada aplikasi multi-tenant tumbuh dari query yang tidak tahu konteksnya. Bug pertama: daftar data tanpa filter — SELECT * FROM projects mengembalikan 5 proyek dari 2 tenant sekaligus, dan pengguna Toko Bunga Melati melihat nama kampanye orang lain. Bug kedua lebih senyap dan lebih berbahaya: pencarian berdasarkan id tanpa cek kepemilikan — SELECT * FROM projects WHERE id = 3 menampilkan proyek milik tenant mana pun kepada siapa pun yang menebak nomornya. Pola serangan ini punya nama resmi: IDOR (Insecure Direct Object Reference), dan ia tetap bekerja meskipun seluruh daftar sudah ter-filter, karena yang bocor adalah halaman detail.

Kedua bug berakar pada satu penyakit: kode menerima id dari luar tanpa tahu tenant siapa yang sedang meminta. Obatnya adalah membuat identitas tenant menjadi bagian tak terpisahkan dari setiap operasi — bukan konvensi yang diingat programmer, melainkan parameter yang diwajibkan bentuk kode. Itulah pekerjaan dua class pada bagian ini.

Class TenantContext — Identitas yang Tak Bisa Dipalsukan #

TenantContext adalah value object kecil dengan tanggung jawab besar: membawa identitas tenant aktif. Berikut isi lengkap berkas src/TenantContext.php:

php
<?php

declare(strict_types=1);

/**
 * Identitas tenant aktif untuk satu operasi/request.
 *
 * TenantContext menjawab tiga pertanyaan sekaligus: user siapa yang
 * bertindak, di tenant mana, dan dengan role apa. Objek ini IMMUTABLE
 * — seluruh property readonly dan constructor private — sehingga kode
 * tengah request tidak bisa mengganti tenant secara diam-diam.
 *
 * Satu-satunya cara membuat context adalah fromUser(): lookup ke tabel
 * memberships. User yang bukan anggota tenant tidak akan pernah
 * mendapatkan context, berapa pun kali mencoba.
 */
final class TenantContext
{
    /** Konteks yang sedang aktif untuk request ini (per-request holder). */
    private static ?TenantContext $aktif = null;

    private function __construct(
        public readonly int $tenantId,
        public readonly string $slug,
        public readonly int $userId,
        public readonly string $role,
    ) {
    }

    /**
     * Bangun context dari pasangan user + slug tenant.
     *
     * Method ini melakukan lookup keanggotaan: hanya user yang punya
     * baris di memberships untuk tenant tersebut yang mendapatkan
     * context. Mengembalikan null bila user bukan anggota tenant,
     * atau slug tidak dikenal — pemanggil wajib menolak akses.
     */
    public static function fromUser(PDO $pdo, int $userId, string $slug): ?self
    {
        $sql = 'SELECT t.id AS tenant_id, t.slug, m.role
                  FROM memberships m
                  JOIN tenants t ON t.id = m.tenant_id
                 WHERE m.user_id = :user_id
                   AND t.slug = :slug';

        $stmt = $pdo->prepare($sql);
        $stmt->execute(['user_id' => $userId, 'slug' => $slug]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($baris === false) {
            return null;
        }

        return new self(
            tenantId: (int) $baris['tenant_id'],
            slug: (string) $baris['slug'],
            userId: $userId,
            role: (string) $baris['role'],
        );
    }

    /**
     * Pasang context untuk request/sesi saat ini.
     *
     * Dipanggil SEKALI di awal request, setelah resolusi user dan
     * tenant berhasil; kode di bawahnya cukup memanggil get().
     */
    public static function set(self $context): void
    {
        self::$aktif = $context;
    }

    /**
     * Ambil context yang aktif.
     *
     * Melempar RuntimeException bila belum di-set — request tanpa
     * konteks tenant adalah bug, bukan kondisi normal, jadi gagal
     * cepat lebih aman daripada mengembalikan null yang lolos ke
     * query.
     */
    public static function get(): self
    {
        if (self::$aktif === null) {
            throw new RuntimeException(
                'TenantContext belum di-set untuk request ini'
            );
        }

        return self::$aktif;
    }

    /**
     * Lepas context aktif.
     *
     * Dipakai di akhir request dan di antar test agar state statis
     * tidak bocor ke eksekusi berikutnya.
     */
    public static function clear(): void
    {
        self::$aktif = null;
    }
}

Enam keputusan pada class tersebut perlu dibedah:

  1. private function __construct + property readonly membuat objek benar-benar immutable: setelah dibuat, tenantId tidak bisa diubah oleh siapa pun. Percobaan menimpa property readonly memicu Error — kita akan melihat buktinya pada walkthrough.
  2. fromUser(PDO $pdo, int $userId, string $slug) adalah satu-satunya pintu masuk. Factory ini melakukan lookup keanggotaan lewat JOIN memberships: user tanpa baris keanggotaan pada tenant tersebut mendapatkan null, sehingga Budi tidak pernah bisa membuat context untuk Studio Foto Cendrawasih walau tahu slugnya.
  3. Named arguments pada new self(...) (tenantId:, slug:, dan seterusnya) membuat pemetaan kolom ke property eksplisit — pembaca langsung tahu t.id masuk ke $tenantId tanpa menecek urutan.
  4. set()/get()/clear() adalah per-request holder: satu tempat menyimpan context aktif. set() dipanggil sekali di awal request, kode di bawahnya cukup TenantContext::get(), dan clear() melepasnya di akhir.
  5. get() melempar exception, bukan mengembalikan null, karena request tanpa konteks tenant adalah bug yang harus gagal keras — null yang lolos ke query justru menjadi lubang diam-diam.
  6. Static property private static ?TenantContext $aktif hidup selama proses PHP berjalan. Pada web SaaS nyata satu proses menangani satu request; pada CLI dan test, clear() menjaga state tidak bocor antar eksekusi.

Sekarang kita uji perilakunya dengan data awal Bagian 1 — Dewi yang menjadi anggota dua tenant, Budi dan Rudi yang tidak:

php
<?php
require __DIR__ . '/src/TenantContext.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'));

$dewiStudio = TenantContext::fromUser($pdo, 3, 'studio-foto-cendrawasih');
echo "Dewi @ studio-foto-cendrawasih => tenant #{$dewiStudio->tenantId}, role {$dewiStudio->role}\n";

$dewiToko = TenantContext::fromUser($pdo, 3, 'toko-bunga-melati');
echo "Dewi @ toko-bunga-melati => tenant #{$dewiToko->tenantId}, role {$dewiToko->role}\n";

$budiStudio = TenantContext::fromUser($pdo, 1, 'studio-foto-cendrawasih');
echo "Budi @ studio-foto-cendrawasih => ";
var_dump($budiStudio);

$rudiToko = TenantContext::fromUser($pdo, 4, 'toko-bunga-melati');
echo "Rudi @ toko-bunga-melati => ";
var_dump($rudiToko);

TenantContext::clear();
try {
    TenantContext::get();
} catch (RuntimeException $e) {
    echo "get() sebelum set(): {$e->getMessage()}\n";
}

TenantContext::set($dewiStudio);
$ctx = TenantContext::get();
echo "Konteks aktif: tenant #{$ctx->tenantId} {$ctx->slug}, user #{$ctx->userId}, role {$ctx->role}\n";

try {
    $ctx->tenantId = 1;
} catch (Error $e) {
    echo "Mutasi property readonly ditolak: {$e->getMessage()}\n";
}

TenantContext::clear();
try {
    TenantContext::get();
} catch (RuntimeException $e) {
    echo "get() setelah clear(): {$e->getMessage()}\n";
}

Output yang Dihasilkan:

text
Dewi @ studio-foto-cendrawasih => tenant #2, role admin
Dewi @ toko-bunga-melati => tenant #1, role member
Budi @ studio-foto-cendrawasih => NULL
Rudi @ toko-bunga-melati => NULL
get() sebelum set(): TenantContext belum di-set untuk request ini
Konteks aktif: tenant #2 studio-foto-cendrawasih, user #3, role admin
Mutasi property readonly ditolak: Cannot modify readonly property TenantContext::$tenantId
get() setelah clear(): TenantContext belum di-set untuk request ini

Empat fakta terbukti pada output tersebut. Dewi mendapatkan dua context berbeda dengan role berbeda (admin dan member) — role memang per pasangan user–tenant, persis seperti rancangan memberships di Bagian 1. Budi dan Rudi mendapatkan NULL untuk tenant tempat mereka bukan anggota; dari titik ini satu-satunya jalan bagi pemanggil adalah menolak akses. Percobaan menimpa $ctx->tenantId ditolak keras oleh Error: Cannot modify readonly property, dan get() setelah clear() kembali melempar exception — state statis tidak menyimpan sisa request sebelumnya.

Class TenantRepository #

Repository pertama proyek ini membaca tabel tenants — tabel yang justru tidak perlu menyaring tenant_id karena isinya adalah daftar seluruh tenant, bukan data milik satu tenant. Berikut isi lengkap src/TenantRepository.php:

php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel tenants.
 *
 * Koneksi PDO disuntikkan lewat constructor sehingga pemanggil yang
 * menentukan database yang dipakai: berkas di disk atau database
 * sementara `sqlite::memory:` untuk pengujian.
 *
 * Perhatikan: tabel tenants sendiri BUKAN milik satu tenant (ia
 * adalah daftar seluruh tenant), jadi query di sini memang tidak
 * menyaring tenant_id. Penyaringan wajib mulai berlaku pada tabel
 * data tenant seperti projects — lihat ProjectRepository pada
 * halaman Bagian 2.
 */
final class TenantRepository
{
    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 tenant berdasarkan slug; null bila tidak ditemukan.
     *
     * Slug adalah pengenal tenant di URL/API, dan schema menjamin
     * keunikannya, sehingga pencarian berbasis slug aman.
     *
     * @return array<string, mixed>|null
     */
    public function findBySlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, slug, dibuat_pada FROM tenants WHERE slug = :slug'
        );
        $stmt->execute(['slug' => $slug]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

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

    /**
     * Daftar tenant tempat seorang user menjadi anggota.
     *
     * Query ini yang membangun "tenant switcher" di antarmuka:
     * user login satu kali, lalu memilih tenant mana yang mau
     * dikerjakan. Keanggotaan dibaca lewat JOIN ke memberships,
     * lengkap dengan role user di tiap tenant.
     *
     * @return list<array<string, mixed>> baris: id, nama, slug, dibuat_pada, role
     */
    public function listForUser(int $userId): array
    {
        $sql = 'SELECT t.id, t.nama, t.slug, t.dibuat_pada, m.role
                  FROM memberships m
                  JOIN tenants t ON t.id = m.tenant_id
                 WHERE m.user_id = :user_id
                 ORDER BY t.nama';

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

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

Dua method tersebut punya dua peran berbeda. findBySlug() melayani resolusi awal request: dari slug di URL menuju baris tenant. listForUser() melayani tenant switcher — antarmuka tempat Dewi memilih bekerja di tenant mana — dan query-nya adalah JOIN memberships dari Bagian 1 dalam wujud nyata: hanya tenant tempat user menjadi anggota yang tampil. Mari kita buktikan:

php
<?php
require __DIR__ . '/src/TenantRepository.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 TenantRepository($pdo);

$toko = $repo->findBySlug('toko-bunga-melati');
echo "findBySlug('toko-bunga-melati'):\n";
echo "  #{$toko['id']} {$toko['nama']} (dibuat {$toko['dibuat_pada']})\n";
echo "findBySlug('toko-tidak-ada') => ";
var_dump($repo->findBySlug('toko-tidak-ada'));

echo "\nlistForUser(3) — Dewi:\n";
foreach ($repo->listForUser(3) as $t) {
    echo "  #{$t['id']} {$t['nama']} ({$t['slug']}) role: {$t['role']}\n";
}

echo "\nlistForUser(4) — Rudi (belum jadi anggota):\n";
echo '  jumlah tenant: ', count($repo->listForUser(4)), "\n";

Output yang Dihasilkan:

text
findBySlug('toko-bunga-melati'):
  #1 Toko Bunga Melati (dibuat 2026-01-05 09:00:00)
findBySlug('toko-tidak-ada') => NULL

listForUser(3) — Dewi:
  #2 Studio Foto Cendrawasih (studio-foto-cendrawasih) role: admin
  #1 Toko Bunga Melati (toko-bunga-melati) role: member

listForUser(4) — Rudi (belum jadi anggota):
  jumlah tenant: 0

listForUser(3) mengembalikan tepat dua tenant untuk Dewi, sedangkan listForUser(4) mengembalikan nol untuk Rudi — pengguna yang terdaftar di tabel users tetapi belum memiliki satu pun keanggotaan. Perhatikan juga bahwa kedua method memakai named parameter (:slug, :user_id) dengan nilai yang terikat, bukan penyisipan string ke SQL — kebiasaan dari chapter SQL Injection Prevention yang kita pertahankan di seluruh proyek.

Pola Repository Ter-Scoped #

Sekarang bagian yang paling penting: bagaimana context dan repository bekerja sama menjaga isolasi. Polanya ada dua lapis. Pertama, setiap method repository pada tabel milik tenant menerima tenant_id sebagai parameter eksplisit dan menaruhnya di klausa WHERE. Kedua, tenant_id itu berasal dari TenantContext, bukan dari input pengguna — pengguna hanya boleh memilih antar tenant yang memang dia anggotanya. Sebagai contoh pola, berikut repository untuk tabel projects (didefinisikan langsung pada snippet karena tabel lain akan menyusul di bagian-bagian berikutnya):

php
final class ProjectRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /** @return list<array<string, mixed>> */
    public function listForTenant(int $tenantId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama FROM projects WHERE tenant_id = :tenant_id ORDER BY id'
        );
        $stmt->execute(['tenant_id' => $tenantId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /** @return array<string, mixed>|null */
    public function findForTenant(int $projectId, int $tenantId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, tenant_id, nama FROM projects
              WHERE id = :id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['id' => $projectId, 'tenant_id' => $tenantId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);
        return $baris === false ? null : $baris;
    }
}

listForTenant() memfilter daftar dengan WHERE tenant_id = :tenant_id, dan findForTenant() memakai syarat ganda WHERE id = :id AND tenant_id = :tenant_id — pasangan inilah penangkal IDOR: baris hanya ditemukan bila id proyek dan pemiliknya cocok dengan konteks. Walkthrough berikut menyusun satu alur request utuh: set context, tampilkan proyek, ganti tenant, lalu membandingkan dengan query tanpa filter dan percobaan akses lintas tenant:

php
<?php
require __DIR__ . '/src/TenantContext.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'));

final class ProjectRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    /** @return list<array<string, mixed>> */
    public function listForTenant(int $tenantId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama FROM projects WHERE tenant_id = :tenant_id ORDER BY id'
        );
        $stmt->execute(['tenant_id' => $tenantId]);
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /** @return array<string, mixed>|null */
    public function findForTenant(int $projectId, int $tenantId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, tenant_id, nama FROM projects
              WHERE id = :id AND tenant_id = :tenant_id'
        );
        $stmt->execute(['id' => $projectId, 'tenant_id' => $tenantId]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);
        return $baris === false ? null : $baris;
    }
}

function tampilkanProyek(TenantContext $ctx, ProjectRepository $repo): void
{
    echo "Tenant aktif: #{$ctx->tenantId} {$ctx->slug} (role {$ctx->role})\n";
    foreach ($repo->listForTenant($ctx->tenantId) as $p) {
        echo "  #{$p['id']} {$p['nama']}\n";
    }
}

$repo = new ProjectRepository($pdo);

echo "== Request A: Dewi membuka Toko Bunga Melati ==\n";
TenantContext::set(TenantContext::fromUser($pdo, 3, 'toko-bunga-melati'));
tampilkanProyek(TenantContext::get(), $repo);

echo "\n== Request B: Dewi berpindah ke Studio Foto Cendrawasih ==\n";
TenantContext::clear();
TenantContext::set(TenantContext::fromUser($pdo, 3, 'studio-foto-cendrawasih'));
tampilkanProyek(TenantContext::get(), $repo);

echo "\n== Query TANPA filter tenant — kebocoran yang harus dihindari ==\n";
foreach ($pdo->query('SELECT id, tenant_id, nama FROM projects ORDER BY id') as $p) {
    echo "  #{$p['id']} [tenant #{$p['tenant_id']}] {$p['nama']}\n";
}

echo "\n== Akses lintas tenant lewat id proyek (IDOR) ==\n";
$ctxToko = TenantContext::fromUser($pdo, 3, 'toko-bunga-melati');
echo "Proyek #3 dari konteks tenant #1: ";
var_dump($repo->findForTenant(3, $ctxToko->tenantId));
$ctxStudio = TenantContext::fromUser($pdo, 3, 'studio-foto-cendrawasih');
$proyek = $repo->findForTenant(3, $ctxStudio->tenantId);
echo "Proyek #3 dari konteks tenant #2: #{$proyek['id']} [tenant #{$proyek['tenant_id']}] {$proyek['nama']}\n";
TenantContext::clear();

Output yang Dihasilkan:

text
== Request A: Dewi membuka Toko Bunga Melati ==
Tenant aktif: #1 toko-bunga-melati (role member)
  #1 Website Baru
  #2 Kampanye Valentine

== Request B: Dewi berpindah ke Studio Foto Cendrawasih ==
Tenant aktif: #2 studio-foto-cendrawasih (role admin)
  #3 Fotografi Produk Sabun
  #4 Branding Ulang 2026
  #5 Katalog Musim Hujan

== Query TANPA filter tenant — kebocoran yang harus dihindari ==
  #1 [tenant #1] Website Baru
  #2 [tenant #1] Kampanye Valentine
  #3 [tenant #2] Fotografi Produk Sabun
  #4 [tenant #2] Branding Ulang 2026
  #5 [tenant #2] Katalog Musim Hujan

== Akses lintas tenant lewat id proyek (IDOR) ==
Proyek #3 dari konteks tenant #1: NULL
Proyek #3 dari konteks tenant #2: #3 [tenant #2] Fotografi Produk Sabun

Bacalah output itu sebagai empat pengujian isolasi yang lulus. Request A menampilkan 2 proyek milik tenant #1 dan Request B menampilkan 3 proyek milik tenant #2 — database yang sama, tabel yang sama, hasil yang menyusut mengikuti konteks. Bagian “TANPA filter” memperlihatkan lawannya: query tanpa tenant_id mengembalikan 5 baris dari 2 tenant, bukti bahwa isolasi bukan berasal dari database melainkan dari disiplin kode. Terakhir, proyek #3 yang diminta dari konteks tenant #1 menghasilkan NULL meskipun barisnya ada — syarat ganda id + tenant_id menolaknya — dan baru ditemukan saat konteksnya benar. Sebuah halaman detail yang menulis “404” alih-alih menampilkan data orang lain: itulah perilaku yang diinginkan.

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-2-tenant-isolation/
    ├── src/
    │   ├── TenantContext.php
    │   └── TenantRepository.php
    └── uji-isolasi.php

Simpan salah satu walkthrough di atas sebagai uji-isolasi.php di dalam folder bagian-2-tenant-isolation, lalu jalankan dari folder tersebut:

bash
php uji-isolasi.php

Path __DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql' menunjuk satu tingkat ke atas lalu masuk folder bagian saudara, sehingga struktur folder ini harus dipertahankan. Seluruh contoh memakai :memory: — tidak ada berkas database yang tersisa, dan setiap eksekusi selalu mulai dari data awal yang sama. Snippet ProjectRepository didefinisikan langsung di dalam file demo karena seluruh method-nya juga dibutuhkan berjalan di baris yang sama; pada bagian berikutnya class-class baru kembali hidup sebagai berkas di src/.

Apa yang Berubah #

Dibandingkan Bagian 1 yang hanya memiliki schema dan query manual, proyek kini memiliki dua fondasi isolasi di lapisan kode:

  • Berkas src/TenantContext.php baru: value object immutable (property readonly, constructor private) dengan factory fromUser() yang menolak user non-anggota, plus holder statis set()/get()/clear() untuk konteks per-request.
  • Berkas src/TenantRepository.php baru dengan findBySlug() untuk resolusi slug dan listForUser() yang membaca keanggotaan lewat JOIN memberships — bahan mentah tenant switcher.
  • Pola repository ter-scope terpasang: listForTenant() menyaring daftar, findForTenant() menyaring detail dengan syarat ganda id + tenant_id, dan tenant_id selalu berasal dari context.
  • Isolasi terbukti lewat output: daftar menyusut per konteks, query tanpa filter teridentifikasi sebagai kebocoran, dan akses IDOR lintas tenant ditolak NULL.

Ringkasan #

TenantContext membuat identitas tenant menjadi objek yang tidak bisa dipalsukan atau diubah diam-diam, dan repository ter-scope membuat penyaringan tenant_id menjadi syarat yang melekat pada setiap query — bukan kebiasaan yang bergantung pada kewaspadaan programmer. Isolasi data hanyalah separuh cerita multi-tenant, karena pertanyaan berikutnya bukan lagi “data siapa yang boleh dilihat” melainkan “siapa boleh melakukan apa di dalam tenant”: member hanya boleh membaca, admin boleh mengelola, owner boleh semuanya. Bagian 3 membangun lapisan itu melalui MembershipRepository dan AccessControl dengan prinsip tolak-secara-default.

Referensi #

Berkas Sumber

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

src/TenantContext.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Identitas tenant aktif untuk satu operasi/request.
 *
 * TenantContext menjawab tiga pertanyaan sekaligus: user siapa yang
 * bertindak, di tenant mana, dan dengan role apa. Objek ini IMMUTABLE
 * — seluruh property readonly dan constructor private — sehingga kode
 * tengah request tidak bisa mengganti tenant secara diam-diam.
 *
 * Satu-satunya cara membuat context adalah fromUser(): lookup ke tabel
 * memberships. User yang bukan anggota tenant tidak akan pernah
 * mendapatkan context, berapa pun kali mencoba.
 */
final class TenantContext
{
    /** Konteks yang sedang aktif untuk request ini (per-request holder). */
    private static ?TenantContext $aktif = null;

    private function __construct(
        public readonly int $tenantId,
        public readonly string $slug,
        public readonly int $userId,
        public readonly string $role,
    ) {
    }

    /**
     * Bangun context dari pasangan user + slug tenant.
     *
     * Method ini melakukan lookup keanggotaan: hanya user yang punya
     * baris di memberships untuk tenant tersebut yang mendapatkan
     * context. Mengembalikan null bila user bukan anggota tenant,
     * atau slug tidak dikenal — pemanggil wajib menolak akses.
     */
    public static function fromUser(PDO $pdo, int $userId, string $slug): ?self
    {
        $sql = 'SELECT t.id AS tenant_id, t.slug, m.role
                  FROM memberships m
                  JOIN tenants t ON t.id = m.tenant_id
                 WHERE m.user_id = :user_id
                   AND t.slug = :slug';

        $stmt = $pdo->prepare($sql);
        $stmt->execute(['user_id' => $userId, 'slug' => $slug]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($baris === false) {
            return null;
        }

        return new self(
            tenantId: (int) $baris['tenant_id'],
            slug: (string) $baris['slug'],
            userId: $userId,
            role: (string) $baris['role'],
        );
    }

    /**
     * Pasang context untuk request/sesi saat ini.
     *
     * Dipanggil SEKALI di awal request, setelah resolusi user dan
     * tenant berhasil; kode di bawahnya cukup memanggil get().
     */
    public static function set(self $context): void
    {
        self::$aktif = $context;
    }

    /**
     * Ambil context yang aktif.
     *
     * Melempar RuntimeException bila belum di-set — request tanpa
     * konteks tenant adalah bug, bukan kondisi normal, jadi gagal
     * cepat lebih aman daripada mengembalikan null yang lolos ke
     * query.
     */
    public static function get(): self
    {
        if (self::$aktif === null) {
            throw new RuntimeException(
                'TenantContext belum di-set untuk request ini'
            );
        }

        return self::$aktif;
    }

    /**
     * Lepas context aktif.
     *
     * Dipakai di akhir request dan di antar test agar state statis
     * tidak bocor ke eksekusi berikutnya.
     */
    public static function clear(): void
    {
        self::$aktif = null;
    }
}

src/TenantRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Lapisan akses data untuk tabel tenants.
 *
 * Koneksi PDO disuntikkan lewat constructor sehingga pemanggil yang
 * menentukan database yang dipakai: berkas di disk atau database
 * sementara `sqlite::memory:` untuk pengujian.
 *
 * Perhatikan: tabel tenants sendiri BUKAN milik satu tenant (ia
 * adalah daftar seluruh tenant), jadi query di sini memang tidak
 * menyaring tenant_id. Penyaringan wajib mulai berlaku pada tabel
 * data tenant seperti projects — lihat ProjectRepository pada
 * halaman Bagian 2.
 */
final class TenantRepository
{
    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 tenant berdasarkan slug; null bila tidak ditemukan.
     *
     * Slug adalah pengenal tenant di URL/API, dan schema menjamin
     * keunikannya, sehingga pencarian berbasis slug aman.
     *
     * @return array<string, mixed>|null
     */
    public function findBySlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nama, slug, dibuat_pada FROM tenants WHERE slug = :slug'
        );
        $stmt->execute(['slug' => $slug]);
        $baris = $stmt->fetch(PDO::FETCH_ASSOC);

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

    /**
     * Daftar tenant tempat seorang user menjadi anggota.
     *
     * Query ini yang membangun "tenant switcher" di antarmuka:
     * user login satu kali, lalu memilih tenant mana yang mau
     * dikerjakan. Keanggotaan dibaca lewat JOIN ke memberships,
     * lengkap dengan role user di tiap tenant.
     *
     * @return list<array<string, mixed>> baris: id, nama, slug, dibuat_pada, role
     */
    public function listForUser(int $userId): array
    {
        $sql = 'SELECT t.id, t.nama, t.slug, t.dibuat_pada, m.role
                  FROM memberships m
                  JOIN tenants t ON t.id = m.tenant_id
                 WHERE m.user_id = :user_id
                 ORDER BY t.nama';

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

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