Isolasi session dan cache agar data antar tenant tidak bocor bukan sekadar masalah kerapian arsitektur, tetapi kontrol keamanan inti pada aplikasi multi-tenant. Kebocoran paling sering terjadi bukan karena database utama salah, melainkan karena state turunan seperti session restore, cache response, memoization, background job, atau cookie yang masih mengacu ke konteks tenant lama.

Solusi praktisnya adalah memastikan setiap lapisan yang menyimpan atau memulihkan state selalu terikat ke identitas yang benar: tenant + user + scope. Selain itu, jangan cache data sensitif dalam konteks yang ambigu, validasi ulang binding identitas setelah restore session, batasi umur data dengan TTL yang tepat, dan pasang guard di worker atau job asinkron agar konteks tenant tidak diwariskan secara salah.

Model ancaman: di mana kebocoran antar tenant biasanya terjadi

Pada sistem multi-tenant, request yang terlihat valid di lapisan HTTP bisa tetap salah konteks di lapisan state. Berikut pola bug yang paling umum:

  • Cache key terlalu umum, misalnya hanya memakai user_id atau resource_id tanpa tenant.
  • Session dipulihkan lintas konteks, misalnya browser berpindah workspace tetapi server masih menganggap session lama valid tanpa verifikasi binding tenant.
  • Cookie tidak dipisah dengan benar, sehingga subdomain atau aplikasi berbeda berbagi session yang seharusnya terisolasi.
  • Background job memakai context global atau state proses sebelumnya, lalu membaca/menulis cache tenant lain.
  • Response cache atau fragment cache menyimpan payload yang mengandung data sensitif per akun tetapi key-nya hanya berdasarkan URL.
  • Connection pooling atau request-local context bocor pada runtime yang memproses banyak request paralel.

Intinya, kebocoran sering muncul ketika sistem menganggap identitas pengguna saja sudah cukup. Pada aplikasi multi-tenant, itu hampir selalu salah. Identitas efektif biasanya minimal terdiri dari:

  • tenant_id
  • user_id
  • scope atau jenis akses, misalnya role, workspace, project, region, atau permission set tertentu
  • bila relevan, session_id atau auth_version untuk mendeteksi session usang

Prinsip desain untuk isolasi session dan cache

1. Semua state turunan harus terikat ke konteks identitas penuh

Jika database query memerlukan tenant_id, maka cache hasil query itu juga harus memerlukan tenant_id. Jika session hanya sah untuk satu workspace aktif, maka session restore harus memverifikasi workspace tersebut sebelum dipakai.

Aturan praktis: bila sebuah nilai tidak aman dibagikan antar tenant, maka key untuk menyimpan nilai itu harus mengandung tenant sebagai bagian eksplisit.

2. Larang cache untuk data sensitif yang salah konteks

Tidak semua data layak di-cache. Hindari atau sangat batasi cache untuk:

  • profil akun yang berubah menurut workspace aktif
  • token, credential, API key, atau data rahasia lain
  • hasil query otorisasi yang bergantung pada role dinamis
  • payload yang memuat daftar resource tenant tanpa key yang lengkap

Jika tetap perlu meng-cache hasil otorisasi atau profil, sertakan semua dimensi konteks yang memengaruhi hasilnya dan gunakan TTL pendek.

3. Hindari context implicit atau global mutable state

Banyak bug muncul karena developer menyimpan tenant aktif di variabel global, singleton, atau thread-local yang tidak di-reset dengan konsisten. Gunakan request context yang eksplisit, diwariskan secara sadar ke fungsi yang membutuhkan, dan dibersihkan di akhir request atau job.

4. Fail closed saat konteks tidak lengkap

Jika middleware tidak bisa menentukan tenant secara tegas, jangan fallback ke tenant terakhir, tenant default, atau cache global. Lebih aman mengembalikan error daripada melanjutkan request dengan konteks salah.

Desain cache key yang aman

Format key yang direkomendasikan

Gunakan namespace yang konsisten dan mudah diaudit. Contoh pola umum:

app:{environment}:{service}:{tenant_id}:{user_id}:{scope}:{resource}:{identifier}:{version}

Tidak semua bagian wajib dipakai sekaligus, tetapi tenant_id hampir selalu wajib untuk data tenant-scoped. scope berguna ketika hasil berbeda berdasarkan role, workspace aktif, atau parameter otorisasi lain.

Contoh:

  • app:prod:api:t_123:u_42:workspace_admin:dashboard:summary:v2
  • app:prod:billing:t_123:subscription:current:v1
  • app:prod:search:t_123:u_42:project_p_9:recent_results:v3

Kesalahan yang sering terjadi

  • user:{user_id}:profile padahal user bisa menjadi anggota banyak tenant.
  • GET:/api/projects/123 sebagai response cache tanpa tenant dan authorization scope.
  • invoice:{invoice_id} ketika ID hanya unik di dalam tenant tertentu atau ketika akses terhadap invoice berbeda per role.

Versi key untuk invalidasi aman

Alih-alih menghapus banyak key satu per satu, gunakan versi namespace. Misalnya setiap tenant memiliki tenant_cache_version. Saat terjadi perubahan besar pada membership, role, atau konfigurasi akses, naikkan versinya. Semua pembacaan cache berikutnya otomatis mengarah ke namespace baru.

cache_key = "tenant:%s:v%s:user:%s:perm_summary" % (tenant_id, tenant_cache_version, user_id)

Pendekatan ini berguna saat invalidasi granular sulit dilakukan, tetapi tetap perlu mekanisme pembersihan key lama agar konsumsi memori tidak membengkak.

TTL: pendek untuk data berisiko, lebih panjang untuk data netral

TTL membantu membatasi durasi dampak jika terjadi salah konteks. Namun TTL bukan pengganti key yang benar. Gunakan pendekatan berikut:

  • TTL pendek untuk data authorization, membership, workspace aktif, atau dashboard personal.
  • TTL sedang/panjang untuk metadata netral tenant yang tidak sensitif dan tidak dipersonalisasi.
  • Tanpa cache untuk data rahasia atau yang berubah sangat cepat sehingga validitas lebih penting daripada performa.

Isolasi session store yang benar

Apa yang harus disimpan dalam session

Session sebaiknya menyimpan informasi minimal yang dibutuhkan untuk melanjutkan identitas, bukan salinan besar dari profil atau authorization snapshot yang mudah usang. Simpan misalnya:

  • session_id
  • user_id
  • tenant_id atau workspace aktif yang sudah dipilih pengguna
  • auth_version atau membership_version untuk mendeteksi perubahan hak akses
  • waktu login, idle timeout, dan metadata keamanan yang relevan

Hindari menyimpan daftar lengkap tenant, role yang lama tidak diperbarui, atau payload hasil query yang besar.

Validasi identity binding setelah restore session

Kesalahan klasik adalah mempercayai session yang baru dipulihkan tanpa mengecek apakah user tersebut masih sah pada tenant aktif. Setelah membaca session dari store, lakukan verifikasi minimal:

  1. session masih valid dan belum kedaluwarsa
  2. user masih aktif
  3. tenant di session masih ada dan dapat diakses user
  4. versi membership/authorization masih cocok
  5. jika request menentukan tenant lewat host/header/path, nilainya harus cocok dengan binding session atau memicu rebind yang aman

Bila salah satu gagal, jangan lanjutkan dengan context lama. Hapus atau regenerasi session dan minta autentikasi ulang atau pemilihan tenant ulang sesuai desain aplikasi.

Pseudo-code middleware validasi session dan tenant

function sessionTenantGuard(request, next) {
  session = sessionStore.load(request.cookies.session_id)
  if (!session || session.isExpired()) {
    return unauthorized("invalid session")
  }

  requestedTenant = resolveTenantFromRequest(request) // subdomain, path, header, dsb
  if (!requestedTenant) {
    return badRequest("tenant context required")
  }

  if (session.tenant_id != requestedTenant.id) {
    // Jangan diam-diam pakai tenant dari session lama
    return forbidden("tenant mismatch")
  }

  user = userRepo.findActiveById(session.user_id)
  if (!user) {
    sessionStore.delete(session.id)
    return unauthorized("user inactive")
  }

  membership = membershipRepo.find(user.id, requestedTenant.id)
  if (!membership || !membership.active) {
    sessionStore.delete(session.id)
    return forbidden("membership invalid")
  }

  if (session.auth_version != membership.auth_version) {
    sessionStore.delete(session.id)
    return unauthorized("stale session")
  }

  request.context = {
    tenant_id: requestedTenant.id,
    user_id: user.id,
    role: membership.role,
    auth_version: membership.auth_version,
    session_id: session.id
  }

  return next(request)
}

Poin terpenting pada pseudo-code di atas adalah binding yang diverifikasi ulang. Session hanya sumber referensi awal, bukan otoritas final untuk konteks tenant.

Session store isolation

Jika Anda menggunakan penyimpanan session terpusat seperti Redis atau database, pastikan namespace key session terpisah dengan jelas dari cache aplikasi lain. Ini tidak otomatis mencegah kebocoran, tetapi mengurangi risiko salah baca, salah hapus, atau reuse key karena naming collision.

Pada sistem besar, pertimbangkan pemisahan tambahan berdasarkan:

  • lingkungan: production, staging, development
  • aplikasi atau service
  • region atau cluster

Untuk arsitektur dengan subdomain per tenant, evaluasi apakah session harus dibatasi per subdomain atau tetap lintas subdomain. Pilih berdasarkan model keamanan aplikasi, bukan sekadar kenyamanan.

Cookie flags dan batasan distribusi cookie

Cookie session adalah pintu masuk pemulihan state. Salah setelan cookie dapat menyebabkan session yang benar dipakai dalam konteks yang salah.

Flag yang umumnya wajib

  • HttpOnly agar tidak mudah diakses JavaScript.
  • Secure agar hanya dikirim lewat HTTPS.
  • SameSite untuk mengurangi risiko pengiriman lintas situs yang tidak diinginkan. Nilai yang tepat bergantung pada pola login dan integrasi aplikasi.

Domain dan path harus disengaja

Jangan memberi domain cookie terlalu luas jika session seharusnya spesifik ke subdomain tertentu. Bila model tenant menggunakan tenant-a.example.com dan tenant-b.example.com, evaluasi dengan hati-hati apakah cookie untuk .example.com memang aman. Dalam banyak kasus, scope domain yang lebih sempit mengurangi kemungkinan reuse session antar konteks yang tidak diinginkan.

Hal yang sama berlaku untuk atribut Path. Jika satu host melayani beberapa aplikasi atau area dengan model autentikasi berbeda, pembatasan path bisa membantu mengurangi tabrakan cookie.

Regenerasi session ID pada peristiwa penting

Regenerasi session ID saat login, logout, perubahan tenant aktif yang sensitif, atau perubahan privilege besar. Tujuannya bukan hanya mencegah fixation, tetapi juga memutus asosiasi dengan state lama yang mungkin sudah tidak valid.

Guard untuk background job, worker, dan task asinkron

Kebocoran lintas tenant sering luput di job asinkron karena worker hidup lama dan memproses banyak tugas berurutan. Context dari job sebelumnya bisa tertinggal jika kode memakai state global atau singleton.

Aturan untuk job multi-tenant

  • Masukkan tenant_id sebagai field eksplisit pada payload job.
  • Masukkan user_id hanya jika benar-benar diperlukan, jangan mengandalkan user global saat runtime.
  • Bangun ulang request context di awal job dan bersihkan di akhir job.
  • Jangan gunakan cache key tanpa tenant meski job hanya berjalan untuk satu customer pada awalnya.
  • Verifikasi bahwa resource yang diproses memang milik tenant yang dikirim di payload.

Pseudo-code guard untuk worker

function handleJob(job) {
  clearProcessContext()

  if (!job.tenant_id) {
    markFailed(job, "missing tenant_id")
    return
  }

  tenant = tenantRepo.findActiveById(job.tenant_id)
  if (!tenant) {
    markFailed(job, "invalid tenant")
    return
  }

  setProcessContext({ tenant_id: tenant.id, job_id: job.id })

  resource = resourceRepo.findById(job.resource_id)
  if (!resource || resource.tenant_id != tenant.id) {
    markFailed(job, "resource tenant mismatch")
    return
  }

  processResource(resource)
}

Jika worker menggunakan koneksi database, cache client, atau object reusable lain, pastikan tidak ada state tenant yang menempel pada instance yang digunakan ulang lintas job.

Contoh kelas bug yang perlu dicari saat audit

1. Cache key tidak menyertakan tenant

// Bug
key = "user:" + userId + ":dashboard"

// Lebih aman
key = "tenant:" + tenantId + ":user:" + userId + ":dashboard"

Bug ini sering tampak aman karena user ID unik secara global. Masalahnya, hasil dashboard bisa bergantung pada tenant aktif, role, atau project yang dipilih.

2. Response cache berbasis URL saja

// Bug
key = request.method + ":" + request.path

// Lebih aman
key = request.method + ":" + request.path + ":tenant:" + tenantId + ":user:" + userId + ":scope:" + scopeHash

Jika endpoint mengembalikan data yang dipersonalisasi atau dibatasi izin, URL saja tidak cukup.

3. Session restore tanpa revalidasi membership

Pengguna keluar dari tenant, role dicabut, atau workspace dipindah, tetapi session lama masih memuat tenant aktif yang dianggap sah. Ini menghasilkan akses salah konteks sampai session kedaluwarsa atau dibersihkan manual.

4. Singleton menyimpan tenant aktif

Pada worker atau server dengan proses panjang, singleton yang menyimpan tenant aktif dapat menyebabkan request berikutnya mewarisi tenant dari request sebelumnya.

5. Cache otorisasi tanpa versi membership

Hak akses pengguna berubah, tetapi cache izin masih dianggap benar karena key tidak memuat auth_version atau tidak diinvalidasi.

6. Data sensitif disimpan di cache bersama

Contohnya token eksternal, hasil sinkronisasi billing, atau profile customer yang mengandung identitas sensitif. Walau key sudah benar, risiko penyalahgunaan dan dampak insiden tetap tinggi. Lebih baik hindari cache untuk kelas data ini kecuali sangat diperlukan.

Checklist audit hardening

  1. Apakah setiap cache key untuk data tenant-scoped selalu memuat tenant_id?
  2. Apakah data yang berubah menurut user atau role juga memuat user_id dan/atau scope?
  3. Apakah ada response cache yang hanya berbasis URL, route name, atau resource ID?
  4. Apakah session restore selalu memverifikasi binding user-tenant-membership?
  5. Apakah session menyimpan snapshot authorization yang bisa usang tanpa auth_version?
  6. Apakah cookie domain/path terlalu luas untuk model tenancy yang dipakai?
  7. Apakah session ID diregenerasi pada login, logout, atau perubahan konteks sensitif?
  8. Apakah worker membersihkan context global sebelum dan sesudah memproses job?
  9. Apakah payload job selalu membawa tenant_id eksplisit?
  10. Apakah resource yang diproses job diverifikasi kepemilikannya terhadap tenant?
  11. Apakah ada singleton, cache in-memory, atau static variable yang menyimpan tenant aktif?
  12. Apakah TTL untuk cache authorization cukup pendek?
  13. Apakah ada mekanisme invalidasi saat membership, role, atau workspace berubah?
  14. Apakah environment, aplikasi, dan service dipisah dalam namespace key?
  15. Apakah log dan metric mampu mendeteksi mismatch tenant pada request, cache, dan job?

Skenario uji regresi yang wajib ada

1. Pergantian workspace cepat pada browser yang sama

Simulasikan user yang berpindah tenant A ke tenant B secara cepat, lalu muat endpoint yang menggunakan session dan cache. Verifikasi tidak ada payload tenant A muncul di tenant B, termasuk widget dashboard, summary, dan response cache.

2. Pencabutan akses saat session masih aktif

Buat session valid, lalu cabut membership user dari tenant. Request berikutnya harus gagal atau memaksa rebind/reauth, bukan tetap mengembalikan data lama.

3. Role berubah tetapi cache authorization masih ada

Naikkan atau turunkan role user lalu akses endpoint yang sebelumnya di-cache. Pastikan hasil mengikuti role terbaru dan key/invalidasi bekerja.

4. Worker memproses job lintas tenant berurutan

Kirim job tenant A lalu tenant B ke worker yang sama. Verifikasi seluruh query, cache write, dan log memakai tenant yang benar untuk masing-masing job.

5. Uji collision pada cache key

Buat dua tenant dengan user atau resource identifier yang mirip. Pastikan key yang dihasilkan tetap berbeda dan tidak saling menimpa.

6. Uji cookie domain/path

Pastikan cookie yang dibuat pada satu host atau path tidak otomatis terbawa ke konteks lain yang seharusnya terisolasi.

Contoh pseudo-test

test("dashboard cache terisolasi per tenant", () => {
  loginAs(user42, tenantA)
  responseA = get("/api/dashboard")
  expect(responseA.body.tenant_id).toEqual("tenantA")

  switchTenant(user42, tenantB)
  responseB = get("/api/dashboard")
  expect(responseB.body.tenant_id).toEqual("tenantB")
  expect(responseB.body).not.toContainDataFrom(responseA.body)
})

Tambahkan pula pengujian negatif: paksa key yang salah di lingkungan test untuk memastikan test benar-benar bisa menangkap kebocoran.

Observability untuk mendeteksi cross-tenant leakage

Pencegahan tidak cukup tanpa deteksi. Anda perlu indikator yang bisa menunjukkan gejala kebocoran sejak dini.

Log terstruktur

Pastikan log request, cache, dan job memuat field berikut bila memungkinkan:

  • tenant_id
  • user_id
  • session_id
  • job_id
  • cache_key_namespace atau hash key
  • resource_tenant_id untuk hasil validasi kepemilikan resource
  • auth_version

Dengan field ini, Anda bisa membuat pencarian untuk kejadian seperti request.tenant_id != resource.tenant_id atau lonjakan tenant mismatch.

Metric yang berguna

  • jumlah error tenant_mismatch
  • jumlah session yang ditolak karena stale_session atau auth_version_mismatch
  • cache hit rate per namespace tenant-scoped
  • jumlah invalidasi membership/role
  • jumlah job gagal karena resource tenant mismatch

Bukan angka hit rate tinggi yang utama, tetapi pola anomali. Cache hit yang sangat tinggi pada endpoint personal bisa menjadi tanda key terlalu umum.

Alert yang layak dipasang

  • ada payload response dengan resource.tenant_id berbeda dari request.tenant_id
  • jumlah mismatch tenant meningkat setelah deploy
  • job worker menulis ke namespace cache tenant yang berbeda dari payload job
  • session restore gagal massal karena perubahan binding yang tidak diantisipasi

Strategi implementasi bertahap

Jika sistem Anda sudah berjalan dan banyak titik state belum terisolasi dengan baik, lakukan hardening bertahap:

  1. Petakan semua penyimpanan state: session, cache, response cache, in-memory cache, queue payload, websocket presence, object storage sementara.
  2. Audit endpoint paling sensitif: dashboard, billing, profil customer, permission, pencarian, export, dan API internal yang sering dipakai UI.
  3. Standarkan pembentukan context di middleware atau gateway, lalu larang pembacaan tenant dari sumber lain yang ambigu.
  4. Standarkan helper pembuat cache key agar developer tidak merakit key manual di banyak tempat.
  5. Tambahkan auth/version binding ke session dan cache yang terkait otorisasi.
  6. Pasang observability sebelum refactor besar agar efek perubahan bisa dipantau.
  7. Tulis regression test untuk bug class utama sebelum memperbaiki implementasi.

Contoh helper pembuat key

function makeTenantScopedKey(parts) {
  if (!parts.tenant_id) throw new Error("tenant_id required")

  let key = [
    "app",
    parts.env,
    parts.service,
    "tenant", parts.tenant_id
  ]

  if (parts.user_id) key.push("user", parts.user_id)
  if (parts.scope) key.push("scope", hash(parts.scope))
  if (parts.resource) key.push(parts.resource)
  if (parts.identifier) key.push(parts.identifier)
  if (parts.version) key.push("v", parts.version)

  return key.join(":")
}

Helper seperti ini memaksa tenant menjadi parameter wajib sehingga peluang developer lupa menambahkannya berkurang.

Trade-off dan batasan

  • Key lebih panjang berarti sedikit tambahan biaya memori dan bandwidth ke store seperti Redis. Biasanya ini layak ditukar dengan isolasi yang benar.
  • TTL pendek menurunkan risiko stale data tetapi dapat menambah beban backend. Pilih TTL berdasarkan sensitivitas data, bukan angka seragam untuk semua endpoint.
  • Verifikasi binding pada setiap request menambah beberapa query atau lookup. Anda bisa menguranginya dengan cache yang tetap aman, misalnya cache membership yang juga tenant-scoped dan versioned.
  • Invalidasi agresif bisa menurunkan hit rate. Namun hit rate tinggi tidak bernilai jika data salah tenant.

Penutup

Isolasi session dan cache agar data antar tenant tidak bocor membutuhkan disiplin lintas seluruh backend, bukan hanya di database. Kunci utamanya adalah menganggap semua state turunan sebagai data yang harus terikat ke identitas efektif: tenant, user, dan scope. Dari sana, terapkan desain key yang aman, validasi identity binding setelah restore session, batasi distribusi cookie, atur TTL dan invalidasi dengan benar, serta pastikan worker dan background job tidak mewarisi konteks tenant secara diam-diam.

Jika Anda hanya mengambil satu tindakan setelah membaca panduan ini, mulailah dengan dua hal: audit semua cache key tenant-scoped dan tambahkan validasi binding user-tenant setelah restore session. Dua langkah tersebut menangani sebagian besar sumber kebocoran yang paling sering terjadi pada aplikasi multi-tenant modern.