Tenavora Unified Platform
Lompat ke konten
Tenavora Engineering 2 menit baca

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:

  1. Migration butuh role privileged. EF migration menjalankan schema DDL, yang akan diblokir RLS kalau Anda tidak grant BYPASSRLS secara eksplisit. Kami pakai role tenavora_migrator terpisah untuk migrate container.

  2. 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.

  3. Health check. Endpoint /health/ready menjalankan db.CanConnectAsync, yang membuka connection tanpa client context — RLS dengan senang hati mengembalikan empty result. Kami special-case ke connection BYPASSRLS.

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.