Insiden keamanan seperti eksploitasi multi-tenant yang diungkap oleh Eaton Works (kasus jnj-webapp-hacks) menunjukkan risiko fatal dari kegagalan validasi konteks tenant. Penyerang dapat melompat antar batas tenant hanya dengan memanipulasi parameter ID request akibat kontrol otorisasi yang hanya mengandalkan logika aplikasi.

Spektrum Arsitektur Multi-Tenancy: Isolasi vs Biaya

Memilih arsitektur multi-tenant adalah kompromi langsung antara batas keamanan (security boundary), kompleksitas operasional, dan efisiensi resource. Tiga pola umum:

  • Logical Separation (Shared Database, Shared Schema): Data seluruh tenant disimpan di tabel yang sama dengan pembeda kolom tenant_id. Overhead biaya sangat rendah, pemanfaatan pooling database optimal, namun risiko Broken Object Level Authorization (BOLA/IDOR) paling tinggi jika aplikasi gagal memfilter tenant_id.
  • Physical / Schema Isolation (Separate Schema / Separate Database): Setiap tenant memiliki skema atau instance database independen. Isolasi data sangat kuat dan blast radius kebocoran data minim. Kelemahannya: biaya infrastruktur membengkak (idle resources), migrasi skema kompleks, serta limitasi connection pool ke database engine.
  • API Gateway Context Routing: Permintaan routing di layer ingress berdasarkan subdomain, header JWT, atau path (misal: tenant.domain.com). Gateway memvalidasi identitas tenant dan menyuntikkan konteks ke downstream service, sering dikombinasikan dengan arsitektur logical atau database-per-tenant.

Matriks Trade-off Arsitektur

ModelBiaya InfraKompleksitas MigrasiKetahanan BOLA/IDORPilihan Terbaik Saat
Shared DB + Row IsolationPaling RendahRendah (Single migration)Rendah (Tergantung filter kode)SaaS B2B/B2C skala masif, budget ketat
Separate SchemaSedangSedang (Loop skema per tenant)Sedang-TinggiEnterprise compliance ringan, volume sedang
Separate DatabasePaling TinggiTinggi (Provisioning, fleet management)Sangat Tinggi (Hardware/Process boundary)Fintech, Healthcare, strict regulatory SLA

Mitigasi di Layer Database: PostgreSQL Row-Level Security (RLS)

Mencegah bug aplikasi membocorkan data tenant lain dilakukan dengan memindahkan penegakan isolasi ke database engine menggunakan Postgres RLS. Koneksi aplikasi menetapkan variabel session tenant sebelum mengeksekusi query.

-- 1. Buat tabel dengan kolom tenant_id
CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL
);

CREATE TABLE documents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES organizations(id),
    title TEXT NOT NULL,
    content TEXT NOT NULL
);

-- Index tenant_id untuk performa filtering RLS
CREATE INDEX idx_documents_tenant_id ON documents(tenant_id);

-- 2. Aktifkan Row-Level Security
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- Berlaku juga untuk table owner jika bukan superuser

-- 3. Definisikan Security Policy berbasis session variable
CREATE POLICY tenant_isolation_policy ON documents
    AS RESTRICTIVE
    FOR ALL
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID);

Jika variabel app.current_tenant_id kosong atau tidak sesuai, database mengembalikan zero rows pada SELECT dan menolak operasi INSERT/UPDATE.

Enforcement Konteks Tenant di Layer Backend (Go)

Aplikasi harus memvalidasi identitas tenant dari klaim token autentikasi (JWT) yang terverifikasi, bukan dari input payload request atau URL parameter yang rentan manipulasi client.

package main

import (
	"context"
	"database/sql"
	"net/http"
	"github.com/google/uuid"
)

type contextKey string
const tenantContextKey contextKey = "tenantID"

// TenantMiddleware memvalidasi klaim tenant dari request context terotentikasi
func TenantMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Asumsi: Authn middleware sebelumnya telah memverifikasi JWT dan menyimpan klaim
		tenantHeader := r.Header.Get("X-Tenant-ID") // Atau ambil langsung dari claims jwt.StandardClaims
		
		tenantID, err := uuid.Parse(tenantHeader)
		if err != nil {
			http.Error(w, "Tenant ID tidak valid atau hilang", http.StatusForbidden)
			return
		}

		ctx := context.WithValue(r.Context(), tenantContextKey, tenantID.String())
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// ExecuteTenantQuery membungkus query dalam transaksi dengan runtime setting postgres
func ExecuteTenantQuery(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) {
	tenantID, ok := ctx.Value(tenantContextKey).(string)
	if !ok || tenantID == "" {
		return nil, sql.ErrNoRows
	}

	tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	// Set session parameter khusus di transaksi ini (LOCAL memastikan reset saat tx berakhir)
	_, err = tx.ExecContext(ctx, "SELECT set_config('app.current_tenant_id', $1, true)", tenantID)
	if err != nil {
		return nil, err
	}

	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}

	return rows, tx.Commit()
}

Prinsip Mencegah Celah Akses Lintas Tenant

  1. Strict Boundary di API Gateway: Validasi keterikatan user ID dan tenant ID pada entry point. Hindari endpoint yang menerima tenant_id langsung dari path parameter tanpa verifikasi hak akses pada JWT token.
  2. Defense in Depth: Gabungkan RLS di level DB dengan validasi di application code. Jangan jadikan ORM query filter (WHERE tenant_id = x) sebagai satu-satunya garis pertahanan.
  3. Connection Pooling Hygiene: Saat memakai session-level variables di Postgres dengan connection pooling (seperti PgBouncer dalam mode transaction), pastikan selalu memakai set_config(..., true) dengan parameter ketiga bernilai true (local to transaction) agar konteks tenant tidak bocor ke request berikutnya.