Pada aplikasi multi-tenant, fitur pencarian bukan sekadar urusan relevansi dan kecepatan. Tantangan yang lebih penting adalah isolasi data: user dari tenant A tidak boleh pernah melihat hasil tenant B, bahkan jika ada bug di sisi frontend atau ada upaya memanipulasi parameter pencarian. Saat menggunakan Laravel Scout dengan Algolia, desain indeks, atribut filter, dan cara membangun query dari backend akan sangat menentukan keamanan sistem.
Artikel ini membahas pendekatan praktis untuk membangun pencarian yang aman pada arsitektur multi-tenant dengan kebutuhan ACL (access control list) berbeda per user. Studi kasus yang digunakan adalah aplikasi SaaS helpdesk, di mana agen hanya boleh mencari tiket dari tenant sendiri, dan pencarian dibatasi lagi oleh status, prioritas, serta assignee.
Masalah inti: cepat saja tidak cukup, pencarian harus aman
Pada Algolia, pencarian sangat cepat karena data sudah diproses menjadi indeks. Namun, justru karena data berada di indeks terpisah dari database utama, Anda harus memastikan atribut yang diperlukan untuk pembatasan akses ikut disimpan di indeks dan selalu digunakan saat query.
Kesalahan umum pada sistem multi-tenant adalah:
- Hanya mengandalkan filter di frontend, sehingga user bisa memodifikasi request.
- Tidak menyimpan
tenant_iddi dokumen indeks. - Menggunakan shared index tetapi lupa memaksa filter tenant di backend.
- Menyimpan atribut ACL, tetapi tidak menandainya sebagai atribut yang dapat difilter.
- Melakukan sinkronisasi indeks yang tidak konsisten, sehingga tiket yang sudah dipindah tenant atau diubah hak aksesnya masih muncul pada hasil lama.
Prinsip utamanya sederhana: semua pembatasan akses harus ditegakkan dari server. Frontend hanya boleh menerima kemampuan pencarian yang sudah dibatasi, bukan menentukan pembatasannya sendiri.
Memilih strategi indeks: index per tenant vs shared index
Index per tenant
Pada pendekatan ini, setiap tenant memiliki indeks Algolia sendiri, misalnya tickets_tenant_123, tickets_tenant_456, dan seterusnya.
Kapan cocok digunakan:
- Jumlah tenant relatif sedikit atau menengah.
- Ukuran data per tenant besar dan butuh pengaturan ranking atau sinonim yang berbeda.
- Kebutuhan isolasi operasional sangat kuat.
- Anda ingin mempermudah penghapusan data tenant tertentu.
Kelebihan:
- Isolasi data lebih sederhana secara konseptual.
- Risiko kebocoran antar tenant lebih kecil karena data terpisah sejak awal.
- Pengaturan relevansi bisa berbeda per tenant bila perlu.
Kekurangan:
- Jumlah indeks bisa meledak jika tenant banyak.
- Operasional lebih rumit: provisioning indeks, settings, reindex, monitoring.
- Perubahan skema atau setting harus diterapkan ke banyak indeks.
Shared index
Pada pendekatan ini, semua dokumen semua tenant masuk ke satu indeks, misalnya tickets, lalu dibedakan dengan atribut seperti tenant_id.
Kapan cocok digunakan:
- Jumlah tenant besar.
- Struktur data antar tenant seragam.
- Anda ingin pengelolaan indeks lebih sederhana.
Kelebihan:
- Manajemen indeks lebih mudah.
- Pengaturan setting cukup satu kali.
- Import massal dan observability lebih terpusat.
Kekurangan:
- Disiplin filter harus sangat ketat.
- Satu bug pada logika filter bisa menyebabkan kebocoran lintas tenant.
- ACL kompleks harus direpresentasikan dengan atribut yang jelas dan konsisten.
Untuk banyak aplikasi SaaS, shared index adalah pilihan yang efisien, tetapi hanya aman jika tenant filter diwajibkan dari server pada setiap query. Jika tenant sedikit dan kebutuhan kustomisasi tinggi, index per tenant sering lebih mudah diamankan.
Desain dokumen indeks yang mendukung multi-tenant dan ACL
Agar filter berjalan dengan benar, dokumen yang dikirim ke Algolia harus membawa semua atribut yang dibutuhkan untuk pembatasan akses dan penyaringan bisnis. Pada studi kasus helpdesk, tiket minimal menyimpan:
objectID: ID unik tiket.tenant_id: identitas tenant.status: misalnyaopen,pending,closed.priority: misalnyalow,medium,high.assignee_id: agen yang menangani tiket.visibilityatau atribut ACL lain bila ada tiket privat/internal.- Field teks untuk pencarian, misalnya
subject,requester_name,messages_preview.
Contoh model Laravel:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
class Ticket extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'tickets';
}
public function toSearchableArray(): array
{
return [
'objectID' => (string) $this->id,
'id' => (int) $this->id,
'tenant_id' => (int) $this->tenant_id,
'subject' => $this->subject,
'requester_name' => optional($this->requester)->name,
'messages_preview' => $this->messages_preview,
'status' => $this->status,
'priority' => $this->priority,
'assignee_id' => $this->assignee_id ? (int) $this->assignee_id : null,
'visibility' => $this->visibility,
'updated_at_ts' => optional($this->updated_at)->timestamp,
];
}
}Perhatikan bahwa tenant_id dan atribut ACL bukan pelengkap; keduanya adalah bagian inti dari model pencarian.
filterOnly dan faceting
Di Algolia, atribut yang ingin dipakai sebagai filter harus didaftarkan pada pengaturan indeks, umumnya melalui attributesForFaceting. Untuk atribut yang hanya dipakai memfilter dan tidak perlu dihitung sebagai facet untuk UI, gunakan filterOnly(...).
Contohnya:
attributesForFaceting:
- filterOnly(tenant_id)
- filterOnly(assignee_id)
- filterOnly(visibility)
- searchable(status)
- searchable(priority)Kapan memakai filterOnly:
- Untuk atribut keamanan seperti
tenant_id. - Untuk atribut ACL yang tidak perlu ditampilkan sebagai agregasi facet.
- Untuk mengurangi overhead facet yang tidak diperlukan.
Kapan facet biasa berguna:
- Saat UI perlu menampilkan jumlah tiket per status atau prioritas.
- Saat pengguna perlu memilih filter dari daftar yang dihitung dari indeks.
Untuk keamanan, tenant_id hampir selalu cocok sebagai filterOnly.
Membangun search rule yang aman di backend
Kesalahan desain yang sangat sering terjadi adalah backend menerima parameter filters mentah dari frontend dan meneruskannya ke Algolia. Ini berbahaya karena user bisa memodifikasi filter dan mencoba melihat data yang bukan haknya.
Strategi yang lebih aman adalah:
- Backend menentukan filter wajib, terutama
tenant_id. - Backend hanya mengizinkan subset filter yang aman, misalnya
status,priority,assignee_id. - Backend melakukan validasi nilai filter.
- Jika ada aturan per-role, backend menyusun filter berdasarkan user yang sedang login.
Contoh service sederhana:
namespace App\Services;
use App\Models\User;
class TicketSearchFilters
{
public function build(User $user, array $input): string
{
$parts = [];
// Wajib: tenant isolation
$parts[] = 'tenant_id:' . (int) $user->tenant_id;
// Contoh ACL: agent biasa hanya boleh lihat tiket yang assigned ke dirinya
if ($user->role === 'agent') {
$parts[] = 'assignee_id:' . (int) $user->id;
}
// Supervisor boleh filter status/prioritas/assignee dalam tenant yang sama
if (!empty($input['status'])) {
$allowed = ['open', 'pending', 'closed'];
if (in_array($input['status'], $allowed, true)) {
$parts[] = 'status:' . $input['status'];
}
}
if (!empty($input['priority'])) {
$allowed = ['low', 'medium', 'high', 'urgent'];
if (in_array($input['priority'], $allowed, true)) {
$parts[] = 'priority:' . $input['priority'];
}
}
if (!empty($input['assignee_id']) && $user->role !== 'agent') {
$parts[] = 'assignee_id:' . (int) $input['assignee_id'];
}
return implode(' AND ', $parts);
}
}Dengan pendekatan ini, frontend tidak pernah punya otoritas untuk menentukan tenant_id atau aturan ACL inti.
Contoh endpoint pencarian yang aman
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Ticket;
use App\Services\TicketSearchFilters;
use Illuminate\Http\Request;
class TicketSearchController extends Controller
{
public function __invoke(Request $request, TicketSearchFilters $filters)
{
$user = $request->user();
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:200'],
'status' => ['nullable', 'string'],
'priority' => ['nullable', 'string'],
'assignee_id' => ['nullable', 'integer'],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = $validated['q'] ?? '';
$filterString = $filters->build($user, $validated);
$page = max(($validated['page'] ?? 1) - 1, 0);
$result = Ticket::search($query, function ($algolia, $queryText, $options) use ($filterString, $page) {
$options['filters'] = $filterString;
$options['page'] = $page;
$options['hitsPerPage'] = 20;
return $algolia->search($queryText, $options);
})->raw();
return response()->json([
'hits' => $result['hits'] ?? [],
'page' => ($result['page'] ?? 0) + 1,
'total_pages' => $result['nbPages'] ?? 0,
'total_hits' => $result['nbHits'] ?? 0,
]);
}
}Pola di atas aman karena:
tenant_idberasal dari user yang terautentikasi, bukan request publik.- Filter user dibatasi dan divalidasi.
- ACL role ditegakkan di server.
Secured API key dan pembatasan dari server
Jika frontend melakukan query langsung ke Algolia, jangan pernah kirim Admin API Key atau Search API Key umum tanpa pembatasan. Gunakan secured API key yang dibuat dari backend dan dibatasi pada indeks, filter, atau masa berlaku tertentu.
Prinsipnya:
- Backend menghitung filter wajib, misalnya
tenant_id:15. - Backend membuat secured API key dengan pembatasan tersebut.
- Frontend hanya bisa mencari dalam ruang yang sudah dibatasi.
Ini penting terutama untuk antarmuka pencarian instan di browser. Namun, meskipun memakai secured API key, validasi dan otorisasi pada backend tetap penting untuk endpoint lain seperti ekspor, analytics, atau operasi lanjutan.
Kesalahan umum adalah menganggap secured API key cukup lalu membiarkan frontend mengirim filter tambahan bebas. Aman atau tidaknya tetap bergantung pada apakah pembatasan kritis sudah di-embed oleh server dan tidak bisa dihapus oleh client.
Studi kasus: SaaS helpdesk dengan pembatasan agen
Misalkan aturan bisnisnya seperti ini:
- Setiap tiket milik tepat satu tenant.
- Agen hanya boleh mencari tiket tenant sendiri.
- Agen biasa hanya boleh melihat tiket yang di-assign ke dirinya.
- Supervisor tenant boleh melihat semua tiket tenant tersebut.
- Filter yang diizinkan:
status,priority,assignee_id(khusus supervisor).
Maka desain pencarian yang aman pada shared index adalah:
- Setiap dokumen tiket menyimpan
tenant_id,status,priority,assignee_id. tenant_idselalu dimasukkan ke filter backend.- Role
agentotomatis mendapatkan tambahan filterassignee_id:{user_id}. - Frontend tidak boleh mengubah tenant scope.
Contoh hasil filter untuk agent tenant 15 dengan ID 88 yang mencari tiket prioritas tinggi:
tenant_id:15 AND assignee_id:88 AND priority:highContoh hasil filter untuk supervisor tenant 15 yang memfilter status open dan assignee 88:
tenant_id:15 AND status:open AND assignee_id:88Dengan pola ini, walaupun user mencoba mengirim tenant_id:99 dari browser, backend tidak akan pernah memakainya.
Import massal, konsistensi update, dan reindex
Import massal
Saat data tiket sudah besar, gunakan chunking atau queue agar indexing tidak membebani request web. Pada Laravel, praktik umum adalah menjalankan impor melalui job antrean dan memproses data secara bertahap.
Hal yang perlu diperhatikan:
- Pastikan relasi yang dibutuhkan untuk
toSearchableArray()di-eager load. - Gunakan ukuran chunk yang masuk akal agar memori stabil.
- Untuk reindex penuh, buat proses yang bisa diulang dan dipantau.
Konsistensi update
Perubahan pada database tidak selalu otomatis berarti indeks langsung konsisten. Dalam praktiknya, ada jeda antara update data dan update hasil pencarian, apalagi bila memakai queue. Ini berarti pencarian bersifat eventually consistent.
Kasus yang wajib ditangani dengan hati-hati:
- Tiket dipindah ke tenant lain.
assignee_idberubah.- Status/priority berubah tetapi hasil pencarian lama masih tampil beberapa detik.
- Tiket dihapus tetapi belum terhapus dari indeks.
Solusinya:
- Pastikan event model yang relevan memicu reindex atau delete dari indeks.
- Untuk operasi sensitif, pertimbangkan sinkronisasi segera atau workflow yang menahan UI sampai job selesai.
- Miliki mekanisme reindex ulang untuk data yang gagal tersinkron.
Strategi reindex yang aman
Jika perubahan skema indeks besar, lakukan reindex terkontrol. Hindari mengubah struktur dokumen tanpa rencana migrasi, karena filter yang bergantung pada atribut lama bisa rusak dan menghasilkan perilaku akses yang tidak diinginkan.
Observability dan debugging
Pencarian aman harus bisa diaudit. Minimal, log hal berikut pada backend:
- ID user dan tenant.
- Query pencarian.
- Filter akhir yang dikirim ke Algolia.
- Role user.
- Durasi request dan respons error.
Jangan log data sensitif berlebihan, tetapi cukupkan untuk investigasi insiden. Ini berguna untuk mendeteksi bug seperti filter tenant hilang pada cabang kode tertentu.
Tips debugging yang praktis:
- Jika hasil pencarian lintas tenant muncul, periksa filter akhir yang benar-benar terkirim, bukan hanya payload dari frontend.
- Periksa apakah
tenant_iddan atribut ACL benar-benar ada di dokumen indeks. - Periksa setting
attributesForFaceting; filter tidak akan bekerja dengan benar jika atribut belum didaftarkan. - Verifikasi bahwa job indexing terbaru sudah selesai diproses.
- Uji role yang berbeda karena bug ACL sering hanya muncul pada kombinasi role tertentu.
Pengujian integrasi yang wajib ada
Untuk sistem seperti ini, unit test saja tidak cukup. Anda perlu integration test yang memverifikasi alur end-to-end: database, pembuatan dokumen indeks, filter backend, dan hasil pencarian.
Contoh skenario uji:
- User tenant A tidak pernah mendapat tiket tenant B.
- Agen hanya melihat tiket yang di-assign ke dirinya.
- Supervisor bisa memfilter
assignee_id, agen biasa tidak bisa. - Perubahan tenant atau assignee pada tiket tercermin di indeks setelah sinkronisasi.
- Request dengan parameter filter ilegal tetap menghasilkan filter aman dari backend.
Jika pengujian langsung ke layanan eksternal terlalu mahal atau lambat untuk CI, Anda bisa memisahkan:
- Test unit/service untuk memverifikasi builder filter.
- Test integrasi terbatas terhadap Algolia pada environment khusus atau pipeline terjadwal.
Fokus utama pengujian adalah memastikan kontrak keamanan: query apa pun dari user tidak boleh menembus batas tenant dan ACL.
Trade-off dan rekomendasi praktis
Jika diringkas, keputusan utamanya adalah:
- Pilih index per tenant bila jumlah tenant tidak terlalu besar dan isolasi operasional lebih penting daripada efisiensi pengelolaan.
- Pilih shared index bila tenant banyak dan skema seragam, tetapi wajib disiplin dengan filter server-side.
Rekomendasi praktis untuk kebanyakan aplikasi SaaS helpdesk:
- Simpan
tenant_iddi setiap dokumen indeks. - Jadikan
tenant_idsebagaifilterOnly. - Simpan atribut ACL seperti
assignee_iddanvisibilitydi indeks. - Bangun filter pencarian di backend, bukan menerima filter mentah dari client.
- Gunakan secured API key untuk pencarian langsung dari browser.
- Tambahkan logging dan test integrasi khusus untuk skenario kebocoran data.
Pada akhirnya, pencarian multi-tenant yang aman bukan ditentukan oleh satu fitur Algolia atau Laravel Scout saja, melainkan oleh disiplin arsitektur. Indeks harus dirancang untuk membawa konteks akses, backend harus memaksa filter yang benar, dan pengujian harus menganggap kebocoran data sebagai skenario utama, bukan kasus pinggiran.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!