Multi-tenant ต้องแยกข้อมูลตั้งแต่วันแรก: RLS, tenant scoping และรูรั่วที่มองไม่เห็น
การมาแปะ tenant isolation ทีหลังคือหายนะที่ผมเจอซ้ำ — "ลืมใส่ WHERE tenant_id ที่เดียว" = ข้อมูลลูกค้ารั่วข้ามกัน มาดูว่าทำไม boundary ต้องบังคับที่ layer ต่ำสุด (RLS/row filter) default ต้อง deny และรูรั่วที่ WHERE clause มองไม่เห็น
อ่าน ~9 นาที
Retrofit isolation ทีหลัง = ทำไมมันแพงและเสี่ยงกว่าที่คิด
ผมเคยดูระบบ SaaS ไทยหลายตัวที่เริ่มจากลูกค้ารายเดียว โค้ดสวย เทสผ่าน ทุกอย่างเรียบร้อย พอลูกค้ารายที่สองเข้ามา ทีมถึงเพิ่งนึกได้ว่า "อ้าว ต้องแยกข้อมูลนะ" แล้วเริ่มไล่แปะ WHERE tenant_id = ? ทีละ query ตรงนี้แหละคือจุดเริ่มของหายนะ
ปัญหาของ retrofit ไม่ใช่แค่งานเยอะ แต่คือมัน ไม่มีทางรู้ว่าครบ ระบบที่โตแล้วมี query หลายร้อยจุด กระจายในหลาย service หลาย background job หลาย report ถ้า isolation เป็นเรื่องที่ "แต่ละ query ต้องจำใส่เอง" การครอบคลุม 100% กลายเป็นเรื่องของ วินัยมนุษย์ ไม่ใช่โครงสร้าง และวินัยมนุษย์พังเสมอเมื่อโค้ดโตพอ
ที่แย่กว่าคือ cost ของการพลาด ในระบบทั่วไป bug = หน้าจอพัง user เห็น error แล้วแจ้ง แต่ในระบบ multi-tenant ที่ลืม filter ระบบทำงานปกติเป๊ะ ไม่มี error — แค่ลูกค้า A เห็นข้อมูลลูกค้า B ความเสียหายคือ data breach ที่อาจไม่มีใครรู้จนกว่าลูกค้าจะ screenshot มาถาม นี่คือเหตุผลที่ผมยืนยันว่า tenant isolation ต้องออกแบบตั้งแต่วันแรก ไม่ใช่ feature ที่ค่อยเติม
สองชั้นของ isolation: application scoping vs database-enforced (RLS)
คนส่วนใหญ่คิดถึง isolation แค่ชั้นเดียว คือ "application ใส่ WHERE ให้ถูก" แต่ที่แข็งแรงจริงต้องมีสองชั้น และชั้นล่างคือชั้นที่ขาดไม่ได้
- ชั้นบน — application scoping: โค้ดฝั่ง app แนบ
tenant_idทุก query ชั้นนี้เร็ว ยืดหยุ่น แต่ เป็น opt-in — พลาดที่เดียวคือรั่ว - ชั้นล่าง — database-enforced (RLS): ฐานข้อมูลบังคับเองว่า connection นี้เห็นได้เฉพาะ row ของ tenant นี้ ต่อให้ app เขียน query ผิด ลืม WHERE หรือมีคน SQL injection ฐานข้อมูลก็ยัง ไม่คืน row ของ tenant อื่น
หลักคิดคือ ชั้นบนไว้เพื่อ correctness ชั้นล่างไว้เพื่อ safety ชั้นบนทำให้โค้ดอ่านง่ายและ optimize ได้ ชั้นล่างคือตาข่ายที่รับเมื่อชั้นบนพลาด และมันจะพลาด ใน Postgres (รวมถึง Supabase ที่ผมใช้บ่อย) ชั้นล่างนี้คือ Row-Level Security
-- เปิด RLS ที่ตัว table — จากนี้ทุก query ต้องผ่าน policy
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
-- policy: เห็น/แก้ได้เฉพาะ row ที่ tenant_id ตรงกับ tenant ใน session
CREATE POLICY tenant_isolation ON bookings
USING (tenant_id = current_setting('app.tenant_id')::uuid) -- อ่าน
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid); -- เขียน
-- ฝั่ง app เซ็ต tenant ต่อ request (ต่อ transaction) แทนที่จะใส่ WHERE เอง
-- SET LOCAL app.tenant_id = '...'; ← ทำครั้งเดียว ครอบทุก query ใน tx
สังเกตว่า WITH CHECK สำคัญพอๆ กับ USING — USING คุมว่า "อ่านอะไรได้" ส่วน WITH CHECK คุมว่า "เขียน row ที่ tenant_id เป็นของคนอื่นไม่ได้" ถ้าลืมตัวหลัง user อาจ insert row สวมรอย tenant อื่นได้ ทั้งที่อ่านไม่เห็น
Default deny: policy ที่ไม่ match = มองไม่เห็น ไม่ใช่ "เห็นหมด"
หัวใจของ RLS ที่ทำให้มันแข็งกว่า WHERE clause คือ ทิศทางของ default พอเปิด RLS แล้วไม่มี policy ไหน match ผลลัพธ์คือ เห็นศูนย์ row ไม่ใช่เห็นหมด นี่คือ fail-closed — พลาดแล้วปลอดภัย
เทียบกับ application scoping ที่เป็น fail-open: ถ้าลืมใส่ WHERE query คืนข้อมูลทุก tenant ความต่างนี้เปลี่ยนธรรมชาติของ bug ทั้งหมด
- Fail-open (แค่ WHERE): ลืม 1 บรรทัด → ข้อมูลรั่วเงียบ ไม่มีสัญญาณ
- Fail-closed (RLS default deny): ลืมตั้ง tenant context → query คืนศูนย์ row → หน้าจอว่าง → dev เห็นทันทีตอน dev
bug ที่ทำให้ "หน้าจอว่างตอน dev" ดีกว่า bug ที่ "รั่วเงียบตอน prod" หลายเท่า เพราะมันบังคับให้คุณเจอตั้งแต่เขียนโค้ด กฎที่ผมยึดคือ ทุก table ที่มี tenant data ต้องเปิด RLS และ default ต้อง deny การเข้าถึงข้อมูลข้าม tenant ต้องเป็นสิ่งที่ "อนุญาตอย่างจงใจ" ไม่ใช่ "ได้มาฟรีเพราะลืมกัน"
เรื่องนี้เชื่อมกับอีก pattern ที่ผมเจอในระบบที่ทำ RBAC แบบ org-tree: legacy mode บางระบบเขียนว่า "ถ้ายังไม่มี org unit เลย (นับได้ 0) → เปิดให้ทุกคนเห็นหมด" เพื่อไม่ให้ของเดิมพัง มันสะดวก แต่มันคือ default-open ที่ซ่อนอยู่ วันที่ migrate ไม่ครบหรือ query นับพลาด ระบบจะ "เปิดหมด" เงียบๆ ถ้าจะทำ legacy mode ต้องรู้ตัวว่ากำลังแลก safety กับ backward-compat และต้องมีแผนปิดมัน
รูรั่วคลาสสิก: shared cache key, background job, admin endpoint
ต่อให้ทุก query มี RLS แล้ว isolation ยังรั่วได้จากทางที่ ไม่ได้ผ่าน SQL WHERE สามจุดนี้คือที่ผมเจอบ่อยสุด
1. Shared cache key ที่ไม่มี tenant ในกุญแจ — cache เป็นชั้นที่อยู่ "เหนือ" ฐานข้อมูล RLS คุมไม่ถึง ถ้า cache key ไม่มี tenant_id ลูกค้า A จะได้ข้อมูลที่ลูกค้า B โหลดไว้ก่อน
// ❌ key ไม่มี tenant → tenant A ได้ค่าที่ tenant B cache ไว้
const key = `settings:${userId}`;
// ✅ tenant เป็นส่วนหนึ่งของ key เสมอ
const key = `t:${tenantId}:settings:${userId}`;
// กฎ: ทุก cache key ที่เก็บ tenant data ต้องขึ้นต้นด้วย tenant namespace
2. Background job / cron ที่ไม่มี tenant context — RLS พึ่ง session variable ที่ตั้งต่อ request แต่ job ที่รันเบื้องหลังไม่มี request มันมักรันด้วย service-role ที่ bypass RLS เพื่อประมวลผลข้ามทุก tenant นี่คือจุดอันตราย เพราะ job ที่ query ผิดจะดึง/แก้ข้าม tenant ได้เต็มที่ กฎคือ job ต้อง loop ทีละ tenant แล้ว ตั้ง tenant context ในแต่ละรอบ อย่ารัน query เดียวข้ามทุก tenant นอกจากตั้งใจจริงและ review เป็นพิเศษ
// ✅ job แบบปลอดภัย: วนทีละ tenant แล้ว scope ให้แคบในแต่ละรอบ
for (const tenantId of await getActiveTenantIds()) {
await db.transaction(async (tx) => {
await tx.raw(`SET LOCAL app.tenant_id = ?`, [tenantId]); // context ต่อรอบ
await sendDailyReminders(tx); // ทุก query ในนี้ถูก RLS คุมอัตโนมัติ
});
}
3. Admin / internal endpoint ที่ตั้งใจ bypass — endpoint แบบ super-admin ที่เห็นข้ามทุก tenant คือ backdoor ที่เราสร้างเอง มันจำเป็น แต่ต้องแยก connection/role ให้ชัด และ endpoint พวกนี้ต้องมี guard ของตัวเองที่เข้มเป็นพิเศษ อย่าใช้ path เดียวกับ user ปกติแล้วเปิด flag เพราะวันที่ flag rule เพี้ยน user ปกติจะได้สิทธิ์ god-mode
Per-tenant secret / encryption key: แยก key ต่อ tenant ได้อะไร
isolation ไม่ได้จบที่ row ในตารางเดียวกัน ระบบที่ผมทำซึ่งเก็บ credential ของลูกค้า (เช่น access token ของ messaging platform ต่อร้าน) ผมเลือก เข้ารหัสด้วย key ที่ derive แยกต่อ tenant ไม่ใช่ key กลางตัวเดียวทั้งระบบ
// derive key ต่อ tenant จาก master key — ไม่เก็บ per-tenant key ตรงๆ
function tenantKey(masterKey, tenantId) {
// HKDF: master + salt(tenantId) → key เฉพาะ tenant นี้
return hkdf(masterKey, tenantId, 'aes-256-gcm', 32);
}
// เข้ารหัส/ถอดรหัส credential ของ tenant ด้วย key ของ tenant นั้นเท่านั้น
ประโยชน์คือ blast radius แคบลง ถ้าโค้ดถอดรหัสพลาดหยิบ key ผิด tenant ผลคือถอดไม่ออก (error) ไม่ใช่ถอดข้อมูลคนอื่นได้ — อีกครั้งที่ fail-closed ช่วยชีวิต และถ้าต้อง revoke/rotate ให้ tenant เดียว ก็ทำได้โดยไม่กระทบคนอื่น หลักการเดียวกันใช้กับ webhook ที่มี endpoint เดียวรับของทุก tenant (เช่น messaging webhook ตัวเดียว): payload ต้องมีตัวระบุ tenant แล้ว route + เลือก secret ของ tenant นั้นมา verify ก่อนประมวลผล อย่า verify ด้วย secret กลางแล้วค่อยเดา tenant ทีหลัง
ทดสอบ isolation: เขียน test ที่จงใจพยายามอ่านข้าม tenant แล้วต้อง fail
การเทสว่า "tenant A เห็นข้อมูลตัวเอง" ไม่ได้พิสูจน์อะไรเรื่อง isolation เลย test ที่พิสูจน์ isolation จริงคือ negative test — จงใจสวมรอยเป็น tenant A แล้วพยายามแตะข้อมูล tenant B และ assert ว่าต้องล้มเหลว
test('tenant A อ่าน booking ของ tenant B ต้องได้ศูนย์ row', async () => {
const bookingB = await seedBooking({ tenantId: TENANT_B });
await withTenant(TENANT_A, async (db) => { // สวมรอยเป็น A
const rows = await db.query('SELECT * FROM bookings WHERE id = $1',
[bookingB.id]);
expect(rows.length).toBe(0); // RLS ต้องกรองทิ้ง ไม่ใช่คืนมา
});
});
test('tenant A insert row สวม tenant_id ของ B ต้องถูก block', async () => {
await withTenant(TENANT_A, async (db) => {
await expect(
db.query('INSERT INTO bookings(tenant_id, ...) VALUES ($1, ...)',
[TENANT_B])
).rejects.toThrow(); // WITH CHECK ต้องปฏิเสธ
});
});
test แบบนี้ควรเป็น template ที่ทุก tenant table ต้องมี และควรรันเป็นส่วนหนึ่งของ CI ถ้าวันหนึ่งมีคนเผลอปิด RLS หรือแก้ policy พลาด test นี้จะแดงทันที มันคือ หลักฐานเชิงพฤติกรรมว่า boundary ทำงาน ไม่ใช่แค่ "อ่านโค้ดแล้วเชื่อว่ามันน่าจะถูก"
Legacy mode / migration: เปิดระบบ tenant บนข้อมูลเก่าที่ยังไม่มี tenant_id
ความจริงคือหลายระบบ retrofit อยู่แล้ว มีข้อมูลเก่าที่ไม่มี tenant_id การเปิด RLS แบบ default-deny บนข้อมูลพวกนี้จะทำให้ทุกอย่าง "หายหมด" ทันที ตรงนี้ต้องมีลำดับที่ปลอดภัย ไม่ใช่กระโดดข้าม
- เพิ่มคอลัมน์ nullable ก่อน แล้ว backfill — เติม
tenant_idให้ครบทุก row เดิมด้วย mapping ที่ตรวจสอบได้ อย่าเดา อย่า default เป็น tenant แรกมั่วๆ - verify ว่าไม่มี row ไหน tenant_id เป็น null ก่อนบังคับ NOT NULL —
SELECT count(*) WHERE tenant_id IS NULLต้องได้ 0 - เปิด RLS ทีหลังสุด เมื่อมั่นใจว่าทุก row มี tenant และ app ตั้ง context ครบทุก path แล้ว
- ถ้ามี "legacy mode" ให้มันชั่วคราวและดังพอ — อย่าให้เงื่อนไข "นับได้ 0 → เปิดหมด" ฝังอยู่เงียบๆ ตลอดกาล ตั้ง flag ให้ log/alert เมื่อยัง active และมี ticket ปิดมันจริง
บทเรียนคือ migration ไปสู่ isolation ต้องเดินจาก fail-open ไป fail-closed อย่างมีสติ ทุกก้าวต้อง verify ด้วย query จริง ไม่ใช่ "รันแล้วน่าจะครบ" เพราะก้าวที่พลาดในเรื่องนี้ไม่ได้แค่ทำ feature พัง มันเปิดข้อมูลลูกค้าให้กันและกัน
สรุป
- ออกแบบ isolation วันแรก — retrofit ทีหลังไม่มีทางรู้ว่าครบ และ bug ประเภทนี้รั่วเงียบไม่มี error ต่างจาก bug ปกติ
- สองชั้น: app scoping (correctness) + RLS (safety) — ชั้นล่างคือตาข่ายที่รับเมื่อชั้นบนพลาด และมันจะพลาด
- Default deny — RLS ที่ไม่ match = ศูนย์ row (fail-closed) หน้าจอว่างตอน dev ดีกว่าข้อมูลรั่วตอน prod; ระวัง legacy mode ที่แอบ default-open
- รูรั่วนอก WHERE — cache key ต้องมี tenant, background job ต้องตั้ง tenant context ต่อรอบ, admin bypass ต้องแยก path และ guard เข้มเป็นพิเศษ
- Per-tenant key — derive key ต่อ tenant ทำให้หยิบผิด = ถอดไม่ออก (fail-closed) และ blast radius แคบ; webhook เดียวต้อง route หา secret ของ tenant ก่อน verify
- Negative test เป็นหลักฐาน — จงใจอ่าน/เขียนข้าม tenant แล้ว assert ว่าล้มเหลว รันใน CI ทุก tenant table
- Migration เดินจาก fail-open ไป fail-closed อย่างมีสติ — backfill → verify ไม่มี null → เปิด RLS ทีหลังสุด ทุกก้าว verify ด้วย query จริง




