Satu titik lemah yang sering luput dari perhatian di aplikasi SaaS multi-tenant adalah ketergantungan penuh pada filter di lapisan aplikasi. Setiap query harus menyertakan where('workspace_id', $currentWorkspaceId) — dan jika satu saja terlewat, data dari satu tenant bisa bocor ke tenant lain. Tidak ada yang mendeteksi. Tidak ada error. Hanya data yang salah.
Row Level Security (RLS) adalah fitur PostgreSQL yang memindahkan tanggung jawab isolasi ini ke level database. Policy yang kita definisikan di sana akan dijalankan secara otomatis untuk setiap query, tanpa perlu aplikasi mengingat apapun. Jika session variable belum diset, hasilnya adalah nol baris — bukan semua data.
Artikel ini membahas cara mengimplementasikan RLS di PostgreSQL untuk aplikasi Laravel, mulai dari mendefinisikan policy di database hingga menyuntikkan konteks tenant lewat middleware.
Dua Pendekatan Multitenancy dan Kelemahannya
Sebelum RLS, ada dua jalur yang umum ditempuh. Pertama, database per tenant — tiap workspace punya database sendiri. Isolasinya sempurna, tapi biaya operasional meledak seiring pertumbuhan jumlah tenant.
Kedua, shared database dengan filter aplikasi — semua tenant berbagi tabel yang sama, dibedakan lewat kolom workspace_id. Migrasi cukup dijalankan sekali, skalabilitas lebih mudah dikelola. Tapi satu bug di query, satu raw SQL yang lupa di-scope, atau satu background job yang tidak punya konteks tenant — semuanya bisa menjadi celah.
RLS menggabungkan kelebihan keduanya: satu database, tapi dengan isolasi yang dijaga di level mesin database itu sendiri.
| Aspek | Database per Tenant | Filter Aplikasi | RLS |
|---|---|---|---|
| Isolasi data | Sempurna | Bergantung kode | Dijamin database |
| Biaya operasional | Tinggi | Rendah | Rendah |
| Migrasi | N kali jalan | Satu kali | Satu kali |
| Jika filter terlewat | Tidak berlaku | Data bocor | Zero rows |
Cara Kerja RLS di PostgreSQL
RLS bekerja dengan mengevaluasi ekspresi Boolean pada setiap baris sebelum mengembalikan hasil query. Ketika policy aktif dan tidak ada baris yang memenuhi kondisi, query mengembalikan hasil kosong — bukan error, bukan data yang tidak diinginkan.
Untuk multitenancy berbasis session variable, alurnya seperti ini: aplikasi menyimpan identifier tenant di session PostgreSQL (SET LOCAL app.current_workspace = 'xyz'), lalu setiap policy membaca variabel tersebut lewat fungsi current_setting(). Satu user database dipakai untuk semua tenant — tidak perlu membuat user database baru per workspace.
SET LOCAL membatasi nilai hanya untuk transaksi yang sedang berjalan, sedangkan SET berlaku untuk seluruh session koneksi. Dalam aplikasi dengan connection pooling, SET LOCAL di dalam transaksi adalah pilihan yang lebih aman.
Menyiapkan Skema Database
Pertama, buat tabel untuk aplikasi manajemen proyek dengan kolom workspace_id sebagai penanda tenant.
-- migrations/001_create_workspaces.sql
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
assignee TEXT,
done BOOLEAN DEFAULT FALSE
);
Perhatikan bahwa tabel tasks tidak menyimpan workspace_id secara langsung — ia hanya terhubung ke projects. Nanti kita akan membuat policy yang menangani ini lewat join.
Mendefinisikan RLS Policy
Aktifkan RLS pada tabel projects, lalu buat policy yang memfilter berdasarkan workspace_id menggunakan session variable.
-- Aktifkan RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Buat policy untuk semua operasi
CREATE POLICY workspace_isolation ON projects
USING (
workspace_id = NULLIF(current_setting('app.current_workspace', TRUE), '')::uuid
);
Fungsi current_setting('app.current_workspace', TRUE) membaca session variable dengan parameter TRUE agar tidak melempar error jika variabel belum diset — melainkan mengembalikan string kosong. NULLIF mengubah string kosong menjadi NULL, sehingga seluruh ekspresi menjadi NULL, yang berarti tidak ada baris yang lolos.
Untuk tabel tasks yang tidak punya workspace_id langsung, kita pakai subquery:
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY workspace_isolation ON tasks
USING (
project_id IN (
SELECT id FROM projects
WHERE workspace_id = NULLIF(current_setting('app.current_workspace', TRUE), '')::uuid
)
);
Policy ini otomatis memanfaatkan policy pada projects yang sudah aktif — RLS berlapis secara alami.
Middleware Laravel untuk Menyuntikkan Tenant Context
Di Laravel, cara paling bersih untuk set session variable adalah lewat middleware yang berjalan setelah autentikasi. Middleware ini menjalankan SET LOCAL di dalam transaksi database setiap request.
<?php
// app/Http/Middleware/SetWorkspaceContext.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class SetWorkspaceContext
{
public function handle(Request $request, Closure $next): mixed
{
$user = Auth::user();
if ($user && $user->current_workspace_id) {
DB::statement(
"SET LOCAL app.current_workspace = ?",
[$user->current_workspace_id]
);
}
return $next($request);
}
}
SET LOCAL di sini beroperasi dalam konteks transaksi implisit setiap request. Untuk memastikan variabel benar-benar terbatas pada scope yang tepat, lebih baik bungkus seluruh request dalam satu transaksi eksplisit.
<?php
// app/Http/Middleware/SetWorkspaceContext.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class SetWorkspaceContext
{
public function handle(Request $request, Closure $next): mixed
{
$user = Auth::user();
if (!$user || !$user->current_workspace_id) {
return $next($request);
}
return DB::transaction(function () use ($request, $next, $user) {
DB::statement(
"SET LOCAL app.current_workspace = ?",
[$user->current_workspace_id]
);
return $next($request);
});
}
}
Daftarkan middleware ini di bootstrap/app.php setelah middleware autentikasi:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('web', [
\App\Http\Middleware\SetWorkspaceContext::class,
]);
$middleware->appendToGroup('api', [
\App\Http\Middleware\SetWorkspaceContext::class,
]);
})
Menangani Context di Luar Request HTTP
Background jobs, scheduled commands, dan artisan commands tidak melewati middleware HTTP. Mereka butuh cara sendiri untuk menginject konteks tenant.
Buat helper sederhana yang bisa dipakai di mana saja:
<?php
// app/Support/WorkspaceScope.php
namespace App\Support;
use Illuminate\Support\Facades\DB;
class WorkspaceScope
{
public static function run(string $workspaceId, callable $callback): mixed
{
return DB::transaction(function () use ($workspaceId, $callback) {
DB::statement("SET LOCAL app.current_workspace = ?", [$workspaceId]);
return $callback();
});
}
}
Penggunaannya di dalam job atau command:
<?php
// app/Jobs/GenerateProjectReport.php
namespace App\Jobs;
use App\Models\Project;
use App\Support\WorkspaceScope;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class GenerateProjectReport implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $workspaceId,
public readonly string $projectId,
) {}
public function handle(): void
{
WorkspaceScope::run($this->workspaceId, function () {
$project = Project::findOrFail($this->projectId);
// proses laporan — hanya bisa akses data workspace yang sesuai
});
}
}
Pola yang sama berlaku untuk php artisan commands yang perlu memproses data per-tenant.
Memverifikasi RLS Berjalan dengan Benar
Sebelum deploy ke production, penting untuk membuktikan bahwa policy benar-benar aktif dan fail-closed. Buat sebuah test yang mengecek perilaku ini secara eksplisit.
<?php
// tests/Feature/WorkspaceIsolationTest.php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Workspace;
use App\Support\WorkspaceScope;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkspaceIsolationTest extends TestCase
{
use RefreshDatabase;
public function test_project_only_visible_within_its_workspace(): void
{
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceScope::run($workspaceA->id, function () use ($workspaceA) {
Project::factory()->create(['workspace_id' => $workspaceA->id]);
});
// Dalam konteks workspace B, project milik A tidak boleh terlihat
WorkspaceScope::run($workspaceB->id, function () {
$this->assertCount(0, Project::all());
});
// Verifikasi fail-closed: tanpa context, tidak ada yang terlihat
$this->assertCount(0, Project::all());
}
}
Test ketiga di akhir — tanpa WorkspaceScope::run sama sekali — adalah yang paling kritis. Ini membuktikan bahwa ketika konteks tenant tidak diset, database mengembalikan nol baris, bukan semua data.
Hal yang Perlu Diperhatikan
Ada beberapa gotcha yang sering muncul saat implementasi RLS di production:
- Connection pooling — PgBouncer dalam mode
transaction poolingkompatibel denganSET LOCAL, tapi dalam modesession pooling, session variable bisa bocor antar koneksi. Pastikan gunakanSET LOCALdi dalam transaksi eksplisit. - Superuser bypass — User PostgreSQL dengan privilese
SUPERUSERatauBYPASSRLSmelewati semua policy. Pastikan user database yang dipakai Laravel bukan superuser. - Raw queries —
DB::select()dan raw SQL tetap diproteksi RLS selama dijalankan dalam koneksi yang sama. Yang berbahaya adalah koneksi database baru yang dibuka di luar siklus request normal. FORCE ROW LEVEL SECURITY— Secara default, owner tabel bisa bypass RLS. TambahkanALTER TABLE projects FORCE ROW LEVEL SECURITY;jika user database Laravel adalah owner tabel tersebut.
Jangan gunakan SET app.current_workspace (tanpa LOCAL) di dalam aplikasi yang menggunakan connection pooling. Nilai akan persisten di session dan bisa terbawa ke request berikutnya dari tenant yang berbeda jika koneksi di-reuse.
Kesimpulan
RLS PostgreSQL mengubah isolasi data dari tanggung jawab developer menjadi jaminan database. Dalam arsitektur SaaS Laravel, ini berarti satu bug yang lupa menambahkan scope tidak lagi menjadi celah keamanan — melainkan sekadar mengembalikan hasil kosong. Untuk menjelajahi lebih lanjut, Tenancy for Laravel v4 menyediakan abstraksi yang lebih lengkap di atas mekanisme RLS ini, cocok untuk aplikasi yang sudah kompleks.