แนวทาง RLS ของ PostgreSQL สำหรับแอปมัลติเทนแนนท์
เรียนรู้ PostgreSQL row-level security ด้วยแพทเทิร์นปฏิบัติสำหรับการแยกผู้เช่าและกฎบทบาท เพื่อบังคับใช้การเข้าถึงที่ระดับฐานข้อมูล ไม่ใช่แค่ในแอป

ทำไมการบังคับใช้ที่ฐานข้อมูลถึงสำคัญในแอปธุรกิจ
แอปธุรกิจมักมีกฎเช่น “ผู้ใช้เห็นได้เฉพาะข้อมูลของบริษัทตัวเอง” หรือ “มีเฉพาะผู้จัดการเท่านั้นที่อนุมัติการคืนเงินได้” ทีมงานหลายทีมบังคับกฎเหล่านี้ที่ UI หรือ API แล้วคิดว่าพอแล้ว แต่ปัญหาคือทุกเส้นทางเพิ่มเติมไปยังฐานข้อมูลเป็นโอกาสให้ข้อมูลรั่วได้: เครื่องมือแอดมินภายใน งานแบ็กกราวด์ คิวรีวิเคราะห์ endpoint ที่ลืมไว้ หรือบั๊กที่ข้ามการตรวจสอบ
การแยกผู้เช่า (tenant isolation) หมายความว่าลูกค้าหนึ่งราย (ผู้เช่า) จะไม่มีวันอ่านหรือแก้ไขข้อมูลของผู้เช่ารายอื่น แม้จะเผลอก็ตาม การเข้าถึงตามบทบาทหมายความว่าคนภายในผู้เช่าเดียวกันยังมีสิทธิ์ต่างกัน เช่น เจ้าหน้าที่ธรรมดา ผู้จัดการ ฝ่ายการเงิน กฎเหล่านี้อธิบายง่าย แต่ยากที่จะรักษาความสอดคล้องเมื่อกระจายอยู่หลายที่
PostgreSQL row-level security (RLS) เป็นฟีเจอร์ของฐานข้อมูลที่ให้ฐานข้อมูลตัดสินได้ว่าแต่ละคำขอเห็นหรือแก้ไขแถวใด แทนที่จะหวังว่าทุกคิวรีในแอปจะจำ WHERE ที่ถูกต้อง ฐานข้อมูลจะนำกฎมาใช้ให้อัตโนมัติ
RLS ไม่ใช่โล่วิเศษสำหรับทุกอย่าง มันไม่ออกแบบสคีมาให้แทนคุณ ไม่ทดแทนการพิสูจน์ตัวตน และไม่ป้องกันคนที่มีบทบาทฐานข้อมูลทรงพลังอยู่แล้ว (เช่น superuser) นอกจากนี้มันจะไม่ป้องกันความผิดพลาดด้านตรรกะเช่น “คนสามารถอัปเดตแถวที่ตนไม่สามารถเลือกได้” เว้นแต่ว่าคุณเขียนนโยบายทั้งอ่านและเขียน
สิ่งที่คุณได้คือตาข่ายความปลอดภัยที่แข็งแรง:
- ชุดกฎเดียวสำหรับทุกเส้นทางโค้ดที่เข้าถึงฐานข้อมูล
- ลดความผิดพลาดเมื่อฟีเจอร์ใหม่ถูกปล่อย
- การตรวจสอบชัดเจนขึ้น เพราะกฎการเข้าถึงอยู่ใน SQL
- การป้องกันที่ดีกว่าเมื่อบั๊กใน API หลุดผ่าน
มีค่าใช้จ่ายการตั้งค่าบ้าง คุณต้องมีวิธีสม่ำเสมอในการส่งข้อมูล "ใครเป็นผู้ใช้" และ "ผู้เช่าคือใคร" เข้าไปในฐานข้อมูล และต้องดูแลนโยบายเมื่อแอปเติบโต ผลตอบแทนค่อนข้างมาก โดยเฉพาะสำหรับ SaaS และเครื่องมือภายในที่เก็บข้อมูลลูกค้าที่สำคัญ
พื้นฐานของ Row-Level Security แบบไม่ใช้ศัพท์เทคนิค
Row-Level Security (RLS) จะกรองแถวที่คำขอสามารถเห็นหรือแก้ไขโดยอัตโนมัติ แทนที่จะพึ่งพาทุกหน้าจอ endpoint หรือรายงานให้ "จำ" กฎ ฐานข้อมูลจะใช้กฎให้คุณ
กับ PostgreSQL RLS คุณเขียนนโยบายที่จะถูกตรวจสอบสำหรับทุก SELECT, INSERT, UPDATE และ DELETE ถ้านโยบายบอกว่า "ผู้ใช้คนนี้เห็นได้เฉพาะแถวของผู้เช่า A" หน้าแอดมินที่ลืมไป คิวรีใหม่ หรือ hotfix ที่รีบร้อนก็ยังมีการป้องกันเหมือนกัน
RLS แตกต่างจาก GRANT/REVOKE GRANT ตัดสินว่าบทบาทใดเข้าถึงตารางได้หรือไม่ได้ (หรือคอลัมน์บางคอลัมน์) ส่วน RLS ตัดสินว่าแถวภายในตารางนั้น ๆ เข้าถึงได้เท่าไร ในการใช้งานจริง คุณมักใช้ทั้งคู่: GRANT เพื่อจำกัดว่าใครเข้าถึงตารางได้ และ RLS เพื่อจำกัดสิ่งที่พวกเขาเข้าถึง
มันยังทนทานในโลกที่ยุ่งเหยิงด้วย มุมมอง (views) โดยทั่วไปปฏิบัติตาม RLS เพราะการเข้าถึงตารางที่เป็นแหล่งข้อมูลยังคงเรียกนโยบาย การ join และ subquery ยังคงถูกกรอง ดังนั้นผู้ใช้จะไม่สามารถ "join" เพื่อเข้าถึงข้อมูลผู้อื่นได้ และนโยบายใช้ได้ไม่ว่าจะเป็นไคลเอนต์ใดที่รันคิวรี: โค้ดแอป, คอนโซล SQL, งานแบ็กกราวด์ หรือเครื่องมือรายงาน
RLS เหมาะเมื่อคุณต้องการการแยกผู้เช่าอย่างเข้มงวด มีหลายวิธีในการคิวรีข้อมูลเดียวกัน หรือมีหลายบทบาทที่ใช้ตารางร่วมกัน (พบได้บ่อยใน SaaS และเครื่องมือภายใน) มันอาจเกินความจำเป็นสำหรับแอปเล็ก ๆ ที่มีแบ็กเอนด์เชื่อถือได้เพียงตัวเดียว หรือข้อมูลที่ไม่สำคัญและไม่ออกนอกบริการเดียว หากคุณมีจุดเข้าใช้มากกว่าหนึ่งจุด (เครื่องมือแอดมิน, การส่งออก, BI, สคริปต์) RLS มักคุ้มค่าที่จะลงแรงตั้งแต่แรก
เริ่มจากการทำแผนแผนผังผู้เช่า บทบาท และความเป็นเจ้าของข้อมูล
ก่อนเขียนนโยบายสักอัน ให้ชัดเจนว่าใครเป็นเจ้าของอะไร PostgreSQL RLS ทำงานได้ดีที่สุดเมื่อโมเดลข้อมูลของคุณสะท้อนผู้เช่า บทบาท และความเป็นเจ้าของอยู่แล้ว
เริ่มจากผู้เช่า ในแอป SaaS ส่วนใหญ่ กฎง่าย ๆ คือ: ทุกตารางที่มีข้อมูลลูกค้าร่วมต้องมี tenant_id นั่นรวมถึงตารางที่ดู "ชัดเจน" เช่น ใบแจ้งหนี้ แต่ก็รวมถึงสิ่งที่มักลืมอย่างไฟล์แนบ ความเห็น บันทึกการตรวจสอบ และงานแบ็กกราวด์ด้วย
จากนั้นตั้งชื่อบทบาทที่ผู้ใช้ใช้จริง เก็บชุดบทบาทให้เล็กและเข้าใจง่าย: owner, manager, agent, read-only นี่คือบทบาททางธุรกิจที่คุณจะจับเข้ากับการตรวจสอบนโยบาย (ไม่ใช่บทบาทฐานข้อมูล)
แล้วตัดสินใจว่าแถวถูกเป็นเจ้าของอย่างไร บางตารางเป็นของผู้ใช้คนเดียว (เช่น โน้ตส่วนตัว) อื่น ๆ เป็นของทีม (เช่น inbox ร่วม) การผสมกันโดยไม่มีแผนจะทำให้นโยบายยากอ่านและง่ายจะหลุด
วิธีง่าย ๆ ในการเอกสารกฎของคุณคือให้ตอบคำถามเดิมสำหรับแต่ละตาราง:
- ขอบเขตผู้เช่าคือคอลัมน์ไหน (คอลัมน์ใดบังคับมัน)?
- ใครอ่านแถวได้ (ตามบทบาทและตามความเป็นเจ้าของ)?
- ใครสร้างและอัปเดตแถวได้ (ภายใต้เงื่อนไขใด)?
- ใครลบแถวได้ (มักจะเข้มงวดที่สุด)?
- ข้อยกเว้นใดอนุญาตบ้าง (พนักงานซัพพอร์ต, ออโตเมชัน, การส่งออก)?
ตัวอย่าง: “Invoices” อาจอนุญาตให้ผู้จัดการดูใบแจ้งหนี้ทั้งหมดของผู้เช่า ตัวแทนดูใบแจ้งหนี้ของลูกค้าที่มอบหมายมา และผู้ชมอ่านอย่างเดียวดูได้แต่แก้ไขไม่ได้ ตัดสินล่วงหน้าว่ากฎใดต้องเข้มงวด (การแยกผู้เช่า, การลบ) และกฎใดยืดหยุ่นได้บ้าง (การมองเห็นเพิ่มเติมสำหรับผู้จัดการ) ถ้าคุณสร้างด้วยเครื่องมือ no-code เช่น AppMaster การทำแมปนี้ยังช่วยให้คาดหวัง UI และกฎฐานข้อมูลให้สอดคล้อง
แบบแผนการออกแบบสำหรับตารางมัลติเทนแนนท์
RLS แบบมัลติเทนแนนท์ทำงานได้ดีที่สุดเมื่อตารางของคุณมีรูปลักษณ์ที่คาดเดาได้ ถ้าทุกตารางเก็บผู้เช่าในรูปแบบต่างกัน นโยบายของคุณจะกลายเป็นปริศนา รูปร่างที่สอดคล้องกันทำให้ RLS อ่าน ทดสอบ และรักษาได้ง่ายขึ้น
เริ่มจากเลือกตัวระบุผู้เช่าหนึ่งแบบและใช้มันทุกที่ UUID เป็นที่นิยมเพราะเดายากและสร้างได้ง่ายในหลายระบบ เลขจำนวนเต็มก็ใช้ได้โดยเฉพาะในแอปภายใน Slug (เช่น "acme") อ่านง่ายสำหรับคน แต่เปลี่ยนได้ จึงควรใช้เป็นฟิลด์แสดงผล ไม่ใช่คีย์หลัก
สำหรับข้อมูลที่ขอบเขตอยู่กับผู้เช่า ให้เพิ่มคอลัมน์ tenant_id ในทุกตารางที่เป็นของผู้เช่า และตั้งเป็น NOT NULL เมื่อเป็นไปได้ หากแถวสามารถมีอยู่โดยไม่มียูนิตผู้เช่า นั่นมักเป็นกลิ่นเหม็นของการผสมข้อมูลระดับโลกกับข้อมูลผู้เช่าในตารางเดียว ซึ่งทำให้นโยบาย RLS ยากและเปราะบาง
การทำดัชนีง่ายแต่สำคัญ คิวรีส่วนใหญ่ในแอป SaaS กรองโดย tenant ก่อน แล้วกรองตามฟิลด์ธุรกิจเช่น status หรือ date ค่าเริ่มต้นที่ดีคือดัชนีบน tenant_id และสำหรับตารางที่มีการใช้งานสูง ดัชนีแบบคอมโพสิตเช่น (tenant_id, created_at) หรือ (tenant_id, status) ตามฟิลเตอร์ที่ใช้บ่อย
ตัดสินใจแต่แรกว่าตารางใดเป็น global และตารางใดเป็น tenant-scoped ตาราง global ทั่วไปเช่น countries, currency codes, หรือ plan definitions ตาราง tenant-scoped ได้แก่ customers, invoices, tickets และสิ่งที่ผู้เช่าเป็นเจ้าของ
ถ้าคุณอยากให้ชุดกฎรักษาได้ ให้อยู่ให้แคบ:
- ตารางที่มีขอบเขตผู้เช่า:
tenant_id NOT NULL, เปิด RLS, นโยบายตรวจtenant_idเสมอ - ตารางอ้างอิงระดับโลก: ไม่มี
tenant_id, ไม่มีนโยบายผู้เช่า, อ่านได้สำหรับบทบาทส่วนใหญ่ - ตารางร่วมแต่ควบคุมได้: แยกตารางตามคอนเซปต์ (หลีกเลี่ยงการผสมแถวระดับโลกและผู้เช่า)
ถ้าคุณสร้างด้วยเครื่องมืออย่าง AppMaster ความสอดคล้องนี้จะช่วยได้ เมื่อ tenant_id เป็นฟิลด์มาตรฐาน คุณสามารถนำรูปแบบเดียวกันไปใช้ซ้ำข้ามโมดูลโดยไม่เซอร์ไพรส์
ทีละขั้นตอน: สร้างนโยบายผู้เช่าแรกของคุณ
ชัยชนะแรกที่ดีด้วย PostgreSQL RLS คือการเลือกตารางเดียวที่อ่านได้เฉพาะภายในผู้เช่าเท่านั้น จุดประสงค์ง่าย ๆ คือ: แม้ใครสักคนจะลืม WHERE ใน API ฐานข้อมูลก็จะปฏิเสธการคืนแถวจากผู้เช่าอื่น
เริ่มจากตารางที่มีคอลัมน์ tenant_id:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
เมื่อเปิด RLS พฤติกรรมเริ่มต้นมักทำให้คนประหลาดใจ: ถ้าบทบาทนั้นถูกบังคับด้วย RLS และไม่มีนโยบายที่ตรงกัน SELECT จะคืนแถวเป็นศูนย์ (และการเขียนจะล้มเหลว) นั่นคือสิ่งที่คุณต้องการตอนเริ่มต้น
ตอนนี้เพิ่มนโยบายอ่านแบบขั้นต่ำ ตัวอย่างนี้สมมติว่าแอปของคุณตั้งตัวแปรเซสชันเช่น app.tenant_id หลังการล็อกอิน:
CREATE POLICY invoices_tenant_read
ON invoices
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ต่อไปเพิ่มกฎการเขียน ใน RLS, USING ควบคุมว่าแถวเดิมใดที่คุณสามารถแตะต้องได้ และ WITH CHECK ควบคุมค่าที่คุณอนุญาตให้เขียนเป็นค่าใหม่
CREATE POLICY invoices_tenant_insert
ON invoices
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_update
ON invoices
FOR UPDATE
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
CREATE POLICY invoices_tenant_delete
ON invoices
FOR DELETE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
นโยบายเป็น PERMISSIVE โดยค่าเริ่มต้น หมายความว่านโยบายใดนโยบายหนึ่งสามารถอนุญาตการเข้าถึงได้ เลือก RESTRICTIVE เมื่อคุณต้องการกฎที่ต้องผ่านทั้งหมด (มีประโยชน์เมื่อเพิ่มการ์ดที่สองเช่น "บัญชีที่ใช้งานอยู่เท่านั้น")
เก็บนโยบายให้เล็กและมุ่งสู่บทบาท แทนที่จะเขียนกฎยักษ์กับ OR มากมาย ให้สร้างนโยบายแยกตามผู้ชม (เช่น invoices_tenant_read_app_user และ invoices_tenant_read_support_agent) จะทดสอบง่าย ตรวจทานง่าย และปลอดภัยต่อการเปลี่ยนในอนาคต
ส่งบริบทผู้เช่าและผู้ใช้อย่างปลอดภัย
เพื่อให้ PostgreSQL RLS ทำงาน ฐานข้อมูลต้องรู้ว่า "ใครเป็นคนเรียก" และ "พวกเขาเป็นผู้เช่าคนไหน" นโยบาย RLS เทียบแถวกับค่าที่ฐานข้อมูลอ่านได้เวลารันคิวรี ดังนั้นคุณต้องส่งบริบทนั้นเข้าสู่เซสชัน
รูปแบบทั่วไปคือการตั้งตัวแปรเซสชันหลังการพิสูจน์ตัวตน แล้วให้นโยบายอ่านค่าจาก current_setting() แอปพิสูจน์ตัวตน (เช่น โดยการยืนยัน JWT) แล้วคัดลอกเฉพาะฟิลด์ที่ต้องการ (tenant_id, user_id, role) ลงในการตั้งค่าการเชื่อมต่อฐานข้อมูล
-- Run once per request (or per transaction)
SELECT set_config('app.tenant_id', '3f2a0c3e-9c7b-4d3f-9c5c-3c5e9c5d1a11', true);
SELECT set_config('app.user_id', '8d9c6b1a-6b6d-4e32-9c0d-2bfe6f6c1111', true);
SELECT set_config('app.role', 'support_agent', true);
-- In a policy
-- tenant_id column is a UUID
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
การใช้อาร์กิวเมนต์ตัวที่สาม true ทำให้มันเป็น "local" ต่อทรานแซกชันปัจจุบัน นั่นสำคัญถ้าคุณใช้ connection pooling: การเชื่อมต่อที่ถูกพูลอาจถูกนำกลับมาใช้ซ้ำโดยคำขออื่น ดังนั้นคุณไม่ต้องการให้บริบทผู้เช่าของเมื่อวานค้างอยู่
เติมบริบทจาก JWT claims
ถ้า API ของคุณใช้ JWT ให้ถือ claims เป็นอินพุต ไม่ใช่ความจริง ตรวจสอบลายเซ็นและวันหมดอายุของโทเคนก่อน แล้วคัดลอกเฉพาะฟิลด์ที่ต้องการ (tenant_id, user_id, role) ลงในการตั้งค่าเซสชัน หลีกเลี่ยงการให้ไคลเอนต์ส่งค่าเหล่านี้โดยตรงเป็น header หรือ query param
บริบทที่ขาดหรือไม่ถูกต้อง: ปฏิเสธโดยค่าเริ่มต้น
ออกแบบนโยบายให้ค่าที่ขาดหายทำให้ไม่มีแถว
ใช้ current_setting('app.tenant_id', true) เพื่อให้ค่าที่ขาดกลับ NULL แปลงเป็นชนิดที่ถูกต้อง (เช่น ::uuid) เพื่อให้ฟอร์แมตที่ไม่ถูกต้องล้มเหลวเร็ว และยกเลิกคำขอถ้าตั้งบริบทผู้เช่าหรือผู้ใช้ไม่ได้ แทนที่จะเดาค่าเริ่มต้น
นี้ทำให้การควบคุมการเข้าถึงสอดคล้องแม้คำขอจะข้าม UI หรือเพิ่ม endpoint ใหม่ทีหลัง
แบบบทบาทที่ปฏิบัติได้และรักษาง่าย
วิธีที่ง่ายที่สุดในการรักษานโยบาย PostgreSQL RLS ให้อ่านง่ายคือแยกตัวตนออกจากสิทธิ์ พื้นฐานที่มั่นคงคือมีตาราง users และตาราง memberships ที่เชื่อมผู้ใช้กับผู้เช่าและบทบาท (หรือหลายบทบาท) แล้วนโยบายของคุณสามารถตอบคำถามเดียวได้ว่า: "ผู้ใช้ปัจจุบันมี membership ที่ถูกต้องสำหรับแถวนี้หรือไม่?"
เก็บชื่อบทบาทให้ผูกกับการกระทำจริง ไม่ใช่ชื่อตำแหน่งงาน “invoice_viewer” และ “invoice_approver” มักยืนยาวกว่าสำหรับนโยบายเพราะสามารถเขียนด้วยคำเรียบง่ายได้
นี่คือแพทเทิร์นบทบาทบางอย่างที่ยังคงเรียบง่ายเมื่อแอปเติบโต:
- Owner-only: แถวมี
created_by_user_id(หรือowner_user_id) และการตรวจสอบคือการจับคู่ตรง - Team-only: แถวมี
team_idและนโยบายตรวจว่าผู้ใช้เป็นสมาชิกทีมภายในผู้เช่าเดียวกัน - Approved-only: อ่านอนุญาตเมื่อ
status = 'approved'และเขียนจำกัดแก่ผู้อนุมัติ - กฎผสม: เริ่มเข้มงวด แล้วเพิ่มข้อยกเว้นเล็ก ๆ (เช่น “support ดูได้ แต่เฉพาะภายในผู้เช่า”)
แอดมินข้ามผู้เช่าเป็นจุดที่หลายทีมพลาด จัดการกับพวกเขาอย่างชัดเจน อย่าใช้เป็นทางลัด "superuser" ที่ซ่อนอยู่ สร้างคอนเซปต์แยกเช่น platform_admin (ระดับโลก) และให้มีการตรวจสอบเฉพาะเจาะจงในนโยบาย จะดียิ่งขึ้นถ้าทำให้การเข้าถึงข้ามผู้เช่ามักเป็นแบบอ่านอย่างเดียวเป็นค่าเริ่มต้น และให้การเขียนต้องการบาร์สูงกว่า
การอธิบายนโยบายสำคัญกว่าที่คิด ใส่คอมเมนต์สั้น ๆ เหนือนโยบายแต่ละตัวที่อธิบายเจตนา ไม่ใช่ SQL “ผู้อนุมัติเปลี่ยนสถานะได้ ผู้ชมอ่านได้เฉพาะใบแจ้งหนี้ที่อนุมัติ” หกเดือนต่อมา โน้ตนั้นแหละที่จะช่วยให้การแก้ไขนโยบายปลอดภัย
ถ้าคุณสร้างด้วยเครื่องมือ no-code เช่น AppMaster รูปแบบเหล่านี้ยังใช้ได้ UI และ API ของคุณจะเคลื่อนไหวได้เร็ว แต่กฎในฐานข้อมูลยังคงมั่นคงเพราะพึ่งพา memberships และความหมายของบทบาทที่ชัดเจน
ตัวอย่างสถานการณ์: SaaS ง่าย ๆ กับใบแจ้งหนี้และซัพพอร์ต
สมมติ SaaS เล็ก ๆ ให้บริการหลายบริษัท แต่ละบริษัทเป็นผู้เช่า แอปมีใบแจ้งหนี้ (เงิน) และตั๋วซัพพอร์ต (การช่วยเหลือลูกค้ารายวัน) ผู้ใช้เป็นได้ทั้ง agent, manager, หรือ support
โมเดลข้อมูล (ย่อ): ทุกแถวใบแจ้งหนี้และตั๋วมี tenant_id ตั๋วยังมี assignee_user_id ติดมาด้วย แอปตั้งผู้เช่าและผู้ใช้ปัจจุบันในเซสชันฐานข้อมูลทันทีหลังล็อกอิน
นี่คือวิธีที่ PostgreSQL RLS ลดความเสี่ยงในชีวิตประจำวัน
ผู้ใช้จาก Tenant A เปิดหน้าจอใบแจ้งหนี้และพยายามเดา ID ใบแจ้งหนี้จาก Tenant B (หรือ UI ส่งมันโดยบังเอิญ) คิวรียังคงรัน แต่ฐานข้อมูลคืนแถวเป็นศูนย์เพราะนโยบายต้องการ invoice.tenant_id = current_tenant_id ไม่มีการรั่วไหลเป็น "access denied" เพียงแค่ผลลัพธ์ว่าง
ภายในผู้เช่าเดียวกัน บทบาทจะจำกัดการเข้าถึงต่อไป ผู้จัดการเห็นใบแจ้งหนี้และตั๋วทั้งหมดของผู้เช่า ตัวแทนเห็นเฉพาะตั๋วที่มอบหมายให้เขา บางทีอาจเห็นแบบร่างของตนเอง นี่คือจุดที่ทีมมักพลาดใน API โดยเฉพาะเมื่อตัวกรองเป็นแบบเลือกได้
ซัพพอร์ตเป็นกรณีพิเศษ พวกเขาอาจต้องดูใบแจ้งหนี้เพื่อช่วยลูกค้า แต่ไม่ควรแก้ไขฟิลด์ที่ละเอียดอ่อนเช่น amount, bank_account, หรือ tax_id รูปแบบปฏิบัติได้คือ:
- อนุญาต
SELECTบน invoices สำหรับบทบาท support (ยังคงถูกจำกัดโดยผู้เช่า) - อนุญาต
UPDATEเฉพาะผ่านทาง "เส้นทางที่ปลอดภัย" (เช่น view ที่เปิดเผยคอลัมน์ที่แก้ไขได้ หรือ policy อัปเดตที่เข้มงวดที่ปฏิเสธการเปลี่ยนแปลงฟิลด์ที่ป้องกัน)
ตอนนี้สถานการณ์บั๊ก API ที่เผลอลืมตัวกรอง: endpoint ลืมใส่ตัวกรองผู้เช่าในระหว่าง refactor หากไม่มี RLS มันอาจรั่วใบแจ้งหนี้ข้ามผู้เช่าได้ แต่ด้วย RLS ฐานข้อมูลจะปฏิเสธการคืนแถวภายนอกผู้เช่าเซสชัน จึงทำให้บั๊กเป็นเพียงหน้าจอเสีย ไม่ใช่การละเมิดข้อมูล
ถ้าคุณสร้าง SaaS แบบนี้ใน AppMaster คุณยังอยากให้กฎเหล่านี้อยู่ในฐานข้อมูล การตรวจสอบใน UI ช่วยได้ แต่กฎฐานข้อมูลคือน้ำหนักสุดท้ายเมื่อมีบางอย่างหลุด
ความผิดพลาดทั่วไปและวิธีหลีกเลี่ยง
RLS มีพลัง แต่ความสะเพร่าตัวเล็ก ๆ สามารถเปลี่ยนจาก "ปลอดภัย" เป็น "น่าประหลาดใจ" ปัญหาส่วนใหญ่เกิดเมื่อมีการเพิ่มตารางใหม่ บทบาทเปลี่ยน หรือใครสักคนทดสอบด้วยผู้ใช้ฐานข้อมูลไม่ถูกต้อง
ความล้มเหลวทั่วไปคือการลืมเปิด RLS บนตารางใหม่ คุณอาจเขียนนโยบายอย่างระมัดระวังสำหรับตารางหลัก แล้วเพิ่มตาราง "notes" หรือ "attachments" ในภายหลังแล้วปล่อยมันด้วยสิทธิ์เต็ม ทำให้เป็นนิสัย: ตารางใหม่ต้องเปิด RLS และมีนโยบายอย่างน้อยหนึ่งอัน
กับดักอีกอันคือการไม่ตรงกันของนโยบายข้ามการกระทำ นโยบายที่อนุญาต INSERT แต่บล็อก SELECT จะดูเหมือนว่า "ข้อมูลหาย" ทันทีหลังสร้าง ข้อกลับกันก็เจ็บปวด: ผู้ใช้สามารถอ่านแถวที่พวกเขาไม่สามารถสร้างได้ ดังนั้นพวกเขาจะหาทางแก้ใน UI คิดเป็น flow: "สร้างแล้วดู", "อัปเดตแล้วเปิดใหม่", "ลบแล้วลิสต์" เสมอ
ระวัง SECURITY DEFINER functions พวกมันรันด้วยสิทธิ์ของเจ้าของฟังก์ชัน ซึ่งสามารถข้าม RLS ได้ถ้าคุณไม่ระวัง ถ้าใช้ ให้เก็บฟังก์ชันให้เล็ก ตรวจสอบอินพุต และหลีกเลี่ยง dynamic SQL เว้นแต่จำเป็นจริง ๆ
อย่าไว้วางใจการกรองฝั่งแอปในขณะที่ปล่อยการเข้าถึงฐานข้อมูลเปิด แม้ API ออกแบบดีจะมี endpoint ใหม่ งานแบ็กกราวด์ และสคริปต์ ถ้าบทบาทฐานข้อมูลอ่านได้ทุกอย่าง สักวันจะเกิดปัญหา
เพื่อจับปัญหาเร็ว ให้การตรวจสอบเป็นเรื่องปฏิบัติได้:
- ทดสอบโดยใช้บทบาทฐานข้อมูลเดียวกับที่แอป production ใช้ ไม่ใช่ผู้ใช้แอดมินส่วนตัว
- เพิ่มการทดสอบเชิงลบต่อหนึ่งตาราง: ผู้ใช้จากผู้เช่าอื่นต้องเห็นศูนย์แถว
- ยืนยันว่าสำหรับแต่ละตารางรองรับการกระทำที่คาดหวัง:
SELECT,INSERT,UPDATE,DELETE - ทบทวนการใช้
SECURITY DEFINERและอธิบายเหตุผล - ใส่ "RLS enabled?" ในเช็คลิสต์การรีวิวโค้ดและมิเกรชัน
ตัวอย่าง: ถ้าตัวแทนซัพพอร์ตสร้างโน้ตในใบแจ้งหนี้แต่ดูไม่ได้ มักเกิดจากนโยบาย INSERT โดยไม่มี SELECT ที่ตรงกัน (หรือบริบทผู้เช่าไม่ได้ถูกตั้งสำหรับเซสชันนั้น)
เช็คลิสต์ด่วนเพื่อยืนยันการตั้งค่า RLS ของคุณ
RLS อาจดูถูกต้องในการรีวิวแต่ล้มเหลวในการใช้งานจริง การยืนยันไม่ใช่การอ่านนโยบาย แต่เป็นการพยายามเจาะมันด้วยบัญชีและคิวรีแบบสมจริง ทดสอบแบบที่แอปจะใช้ ไม่ใช่แบบที่คุณหวังว่ามันจะทำงาน
สร้างชุดตัวตนทดสอบเล็ก ๆ ก่อน ใช้อย่างน้อยสองผู้เช่า (Tenant A และ Tenant B) สำหรับแต่ละผู้เช่า ให้เพิ่มผู้ใช้ปกติและแอดมิน/ผู้จัดการ ถ้าคุณรองรับบทบาท "support agent" หรือ "read-only" ให้เพิ่มบทบาทนั้นด้วย
แล้วทดสอบ RLS ด้วยชุดการตรวจสอบซ้ำได้:
- รันการกระทำหลักสำหรับแต่ละบทบาท: ลิสต์แถว ดึงแถวเดี่ยวตาม id, insert, update, delete สำหรับแต่ละการกระทำ ให้ลองกรณีที่ "อนุญาต" และ "ควรถูกบล็อก"
- พิสูจน์ขอบเขตผู้เช่า: ในฐานะ Tenant A พยายามอ่านหรือแก้ไขข้อมูลของ Tenant B โดยใช้ id ที่รู้ว่ามี ผลต้องเป็นศูนย์หรือข้อผิดพลาดสิทธิ์ ไม่เคยเป็น "บางแถว"
- ทดสอบ join เพื่อหาการรั่ว: join ตารางที่ป้องกันเข้ากับตารางอื่น (รวมถึง lookup tables) ยืนยันว่า join ไม่สามารถดึงแถวของผู้เช่าอื่นผ่าน foreign key หรือ view ได้
- ตรวจสอบว่าการขาดหรือบริบทผิดปฏิเสธการเข้าถึง: ล้างบริบท tenant/user และลองอีกครั้ง "ไม่มีบริบท" ควรล้มเหลวแบบปิด นอกจากนี้ลอง tenant id ที่ไม่ถูกต้อง
- ยืนยันประสิทธิภาพพื้นฐาน: ดูแผนคิวรีและตรวจว่าดัชนีรองรับรูปแบบการกรองตามผู้เช่า (โดยทั่วไป
tenant_idบวกสิ่งที่คุณเรียงหรือตาม)
ถ้าการทดสอบใดทำให้ประหลาดใจ แก้นโยบายหรือการตั้งบริบทก่อน อย่าแพตช์ที่ UI หรือ API แล้วหวังว่ากฎฐานข้อมูลจะ "ค้ำไว้"
ขั้นตอนถัดไป: เปิดใช้งานอย่างปลอดภัยและรักษาความสม่ำเสมอ
ปฏิบัติต่อ PostgreSQL RLS เหมือนระบบความปลอดภัย: แนะนำอย่างรอบคอบ ยืนยันบ่อย ๆ และเก็บกฎให้เรียบง่ายพอที่ทีมจะปฏิบัติตาม
เริ่มเล็ก เลือกตารางที่การรั่วไหลจะทำให้เกิดความเสียหายมากที่สุด (payments, invoices, ข้อมูลบุคคลที่เป็นข้อมูลส่วนตัว, ข้อความลูกค้า) และเปิด RLS ที่นั่นก่อน ความสำเร็จเล็ก ๆ ดีกว่าการเปิดใช้งานครั้งใหญ่ที่ไม่มีใครเข้าใจทั้งหมด
ลำดับการเปิดใช้งานที่ปฏิบัติได้มักเป็นดังนี้:
- ตารางหลักที่มีเจ้าของชัดเจนก่อน (แถวเป็นของผู้เช่าชัดเจน)
- ตารางที่มีข้อมูลส่วนบุคคล (PII)
- ตารางที่แชร์แต่กรองตามผู้เช่า (reports, analytics)
- ตารางเชื่อมโยงและกรณีขอบ (many-to-many)
- ทุกอย่างที่เหลือเมื่อพื้นฐานมั่นคง
ทำให้การทดสอบไม่ใช่ตัวเลือก ทดสอบอัตโนมัติควรรันคิวรีเดียวกันในฐานะผู้เช่าและบทบาทต่าง ๆ และยืนยันว่าผลลัพธ์เปลี่ยนตามที่คาด รวมทั้งเช็คทั้ง "ควรอนุญาต" และ "ควรถูกปฏิเสธ" เพราะบั๊กที่แพงที่สุดคือการให้สิทธิ์เกินโดยเงียบ
เก็บจุดเดียวชัดเจนใน flow ของคำขอที่ตั้งบริบทเซสชันก่อนคำถามใด ๆ tenant id, user id, และ role ควรถูกตั้งครั้งเดียว ตั้งแต่ต้น และไม่คาดเดาภายหลัง ถ้าคุณตั้งบริบทกึ่งกลางทรานแซกชัน สุดท้ายจะมีคิวรีที่ทำงานด้วยค่าที่ขาดหรือเก่า
เมื่อคุณสร้างด้วย AppMaster วางแผนให้ UI ที่สร้างโดยระบบและนโยบาย PostgreSQL สอดคล้องกัน มาตรฐานการส่งบริบทผู้เช่าและบทบาทไปยังฐานข้อมูล (เช่น ตัวแปรเซสชันเดียวกันสำหรับทุก endpoint) จะทำให้นโยบายทำงานเหมือนกันทุกที่ ถ้าคุณใช้ AppMaster ที่ appmaster.io, RLS ยังคงคุ้มค่าที่จะถือเป็นตัวตัดสินสุดท้ายสำหรับการแยกผู้เช่า แม้ว่าคุณจะควบคุมการเข้าถึงใน UI ด้วยก็ตาม
สุดท้าย จงสังเกตสิ่งที่ล้มเหลว การปฏิเสธการอนุญาตเป็นสัญญาณมีประโยชน์ โดยเฉพาะหลังการเปิดตัว ติดตามการถูกปฏิเสธซ้ำ ๆ และตรวจสอบว่ามันเป็นการโจมตีจริง ปัญหาของไคลเอนต์ หรือกฎที่เข้มงวดเกินไป
รายการนิสัยสั้น ๆ ที่ช่วยให้ RLS อยู่ได้:
- มุมมองเริ่มจากปฏิเสธโดยค่าเริ่มต้น และเพิ่มข้อยกเว้นอย่างตั้งใจ
- ชื่อนโยบายชัดเจน (ตาราง + การกระทำ + ผู้ชม)
- การเปลี่ยนนโยบายต้องรีวิวเหมือนการเปลี่ยนโค้ด
- บันทึกการปฏิเสธและรีวิวช่วงเปิดตัว
- ชุดทดสอบเล็ก ๆ เพิ่มสำหรับแต่ละตารางที่เปิด RLS


