Row-level security untuk isolasi multi-tenant yang sebenarnya
Mengapa kami menerapkan isolasi tenant di tier PostgreSQL dengan row-level security, bukan di layer aplikasi — dan apa yang kami pelajari saat wire ke EF Core.
Mayoritas produk SaaS multi-tenant menerapkan isolasi di layer aplikasi:
satu kolom tenant_id di setiap tabel, dan filter ORM yang menambahkan
WHERE tenant_id = ? ke setiap query. Cara ini berjalan — sampai ada
seseorang yang menulis raw SQL, lupa filter di join, atau menemui code
path yang filter-nya tidak diaplikasikan. Tiba-tiba Tenant A membaca data
Tenant B, dan Anda baru tahu dari incident report.
Kami mengambil pendekatan berbeda: terapkan isolasi di database, bukan di aplikasi.
PostgreSQL Row-Level Security
PostgreSQL memiliki policy row-level security built-in. Anda deklarasikan
policy di tabel yang mengatakan “row hanya visible jika tenant_id = current_setting('app.client_id')”,
dan sejak saat itu database sendiri yang menerapkannya. Aplikasi men-set
session variable saat connection dibuka, dan setiap query — ORM atau raw
— ter-filter.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (client_id = current_setting('app.client_id')::uuid);
Itu saja. Lupa filter di kode aplikasi? Database mengembalikan zero row.
Coba write ke row tenant lain? Insertion gagal. Escape hatch-nya adalah
SET ROLE ke role privileged — hanya dipakai background job yang punya
kebutuhan cross-tenant yang sah.
Wire ke EF Core
Bagian tricky-nya adalah memastikan setiap connection punya app.client_id
yang ter-set sebelum query apapun jalan. Kami melakukan ini dengan custom
DbConnectionInterceptor yang berjalan saat connection dibuka:
public class ClientRlsConnectionInterceptor : DbConnectionInterceptor
{
public override async ValueTask<InterceptionResult> ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken cancellationToken = default)
{
var clientId = _currentClient.ClientId;
if (clientId is not null)
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT set_config('app.client_id', @id, false)";
cmd.Parameters.Add(new NpgsqlParameter("id", clientId.ToString()));
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
return await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
}
}
Current client di-resolve dari JWT di request pipeline dan flow lewat DI sebagai scoped service. Saat EF benar-benar membuka connection (lazy, sering di dalam query pertama), interceptor men-set session variable.
Apa yang broken saat kami ship
Beberapa hal yang tidak kami antisipasi:
-
Migration butuh role privileged. EF migration menjalankan schema DDL, yang akan diblokir RLS kalau Anda tidak grant
BYPASSRLSsecara eksplisit. Kami pakai roletenavora_migratorterpisah untuk migrate container. -
Connection pooling matters. Npgsql pool connection, dan session variable persist lintas reuse. Kami harus memastikan interceptor berjalan di setiap connection acquisition, bukan hanya yang pertama.
-
Health check. Endpoint
/health/readymenjalankandb.CanConnectAsync, yang membuka connection tanpa client context — RLS dengan senang hati mengembalikan empty result. Kami special-case ke connectionBYPASSRLS.
Hasilnya: bahkan kalau seseorang menulis raw SQL dan lupa WHERE clause, database menolak untuk leak. Itu sepadan dengan upfront complexity.
Kapan TIDAK pakai RLS
Kalau kebutuhan isolasi Anda sederhana dan Anda punya tim kecil yang bisa disiplin dengan penggunaan ORM yang konsisten, app-layer filtering OK. RLS adalah pilihan tepat ketika:
- Anda punya raw SQL di mana-mana (analytics query, reporting)
- Anda butuh defense-in-depth untuk audit compliance
- Anda punya beberapa engineer dan kemungkinan ada yang lupa filter bukan nol
Bagi kami, ketiganya benar. Ketenangan pikiran sepadan dengan investasinya.