Row-level security ของ PostgreSQL สำหรับ SaaS ช่วยบังคับการแยก tenant ในฐานข้อมูล เรียนรู้ว่าเมื่อใดควรใช้ วิธีเขียนนโยบาย และสิ่งที่ควรหลีกเลี่ยง

ในแอป SaaS บั๊กด้านความปลอดภัยที่อันตรายที่สุดมักเป็นบั๊กที่โผล่มาหลังจากที่คุณสเกลขึ้น คุณเริ่มจากกฎง่าย ๆ เช่น “ผู้ใช้เห็นได้เฉพาะข้อมูลของ tenant ของตัวเอง” แล้วคุณปล่อย endpoint ใหม่เร็ว ๆ เพิ่มคำสั่งรายงาน หรือเพิ่ม join ที่โดยไม่ตั้งใจข้ามการตรวจสอบ
การยืนยันสิทธิ์ฝั่งแอปจะพังเมื่อมีความซับซ้อนเพราะกฎกระจายตัว ตัวคอนโทรลเลอร์หนึ่งเช็ก tenant_id อีกตัวเช็กสมาชิก งานแบ็คกราวด์ลืมเช็กไป และทาง "admin export" อยู่ในสถานะ "ชั่วคราว" เป็นเดือน ๆ แม้ทีมรอบคอบก็ยังพลาด spot ได้
Row-level security (RLS) ของ PostgreSQL แก้ปัญหาเฉพาะ: ทำให้ฐานข้อมูลบังคับว่าแถวใดมองเห็นได้สำหรับคำร้องหนึ่ง ๆ แบบเดียวกับที่มิดเดิลแวร์การพิสูจน์ตัวตนกรองคำร้องทุกเม็ด ความคิดหลักง่าย: ทุก SELECT, UPDATE, และ DELETE จะถูกกรองโดยนโยบายอัตโนมัติ
ส่วนคำว่า “แถว” สำคัญ RLS ไม่ได้ปกป้องทุกอย่าง:
ตัวอย่างชัดเจน: คุณเพิ่ม endpoint ที่แสดงรายการ projects พร้อม join กับ invoices เพื่อแดชบอร์ด ฝั่งแอปอย่างเดียว อาจกรอง projects ตาม tenant แต่ลืมกรอง invoices หรือ join บนคีย์ที่ข้าม tenant ได้ง่าย ๆ ด้วย RLS ตารางทั้งสองสามารถบังคับการแยก tenant ได้ ดังนั้นคำสั่งจะล้มอย่างปลอดภัยแทนการรั่วข้อมูล
การแลกเปลี่ยนคือนิสัย: คุณเขียนโค้ดตรวจสอบน้อยลงและลดจำนวนจุดที่อาจรั่ว แต่คุณต้องทำงานใหม่: ออกแบบนโยบายอย่างระมัดระวัง ทดสอบตั้งแต่ต้น และยอมรับว่านโยบายอาจบล็อกคำสั่งที่คุณคิดว่าจะทำงานได้
RLS อาจรู้สึกเป็นงานเพิ่มจนกว่าแอปของคุณจะมี endpoint มากกว่าจำนวนไม่กี่ตัว ถ้าคุณมีขอบเขต tenant ที่ชัดเจนและเส้นทางการดึงข้อมูลหลายแบบ (หน้ารายการ, ค้นหา, การส่งออก, เครื่องมือแอดมิน) การใส่กฎในฐานข้อมูลหมายความว่าคุณไม่ต้องจำเพิ่มตัวกรองเดิมในทุกที่
RLS เหมาะอย่างยิ่งเมื่อกฎน่าเบื่อและเป็นสากล: “ผู้ใช้เห็นได้เฉพาะแถวของ tenant ของตัวเอง” หรือ “ผู้ใช้เห็นเฉพาะโปรเจกต์ที่สมาชิกของเขาเป็นส่วนหนึ่ง” ในกรณีเหล่านั้น นโยบายช่วยลดความผิดพลาดเพราะทุก SELECT, UPDATE, และ DELETE จะผ่านเกตเดียวกัน แม้เมื่อเพิ่มคำสั่ง SQL ใหม่ทีหลัง
มันยังช่วยในแอปที่อ่านหนักเมื่อโลจิกการกรองคงที่ หาก API ของคุณมีวิธีโหลด invoices 15 แบบ (ตามสถานะ, ตามวันที่, ตามลูกค้า, ตามการค้นหา) RLS ช่วยให้คุณหยุดเขียนการกรอง tenant ซ้ำ ๆ ในทุกคำสั่งและโฟกัสที่ฟีเจอร์แทน
RLS จะเพิ่มความเจ็บปวดเมื่อกฎไม่ได้อยู่ในระดับแถว กฎต่อฟิลด์เช่น “คุณเห็นเงินเดือนแต่ไม่เห็นโบนัส” หรือ “ปกปิดคอลัมน์นี้เว้นแต่คุณเป็น HR” มักกลายเป็น SQL ที่อึดอัดและข้อยกเว้นที่ยากดูแล
มันยังไม่เหมาะกับการรายงานหนักที่ต้องการการเข้าถึงกว้างจริง ๆ ทีมมักสร้าง role ข้ามไปเพื่อ “งานนี้งานเดียว” และตรงนั้นแหละที่ความผิดพลาดสะสม
ก่อนตัดสินใจ ให้คิดว่าคุณต้องการให้ฐานข้อมูลเป็นเกตสุดท้ายหรือไม่ ถ้าตอบว่าใช่ วางแผนวินัย: ทดสอบพฤติกรรมฐานข้อมูล (ไม่ใช่แค่ผล API), ถือมิเกรชันเป็นการเปลี่ยนแปลงความปลอดภัย, หลีกเลี่ยงการบายพาสแบบด่วน, ตัดสินใจว่าหน้าที่งานแบ็คกราวด์ยืนยันตัวตนอย่างไร และรักษานโยบายให้เล็กและทำซ้ำได้
ถ้าคุณใช้เครื่องมือที่สร้าง backend อัตโนมัติ มันช่วยเร่งการส่งมอบได้ แต่ไม่ทำให้ความต้องการบทบาทที่ชัดเจน เทสต์ และโมเดล tenant เรียบง่ายหายไป (ตัวอย่างเช่น Koder.ai ใช้ Go และ PostgreSQL สำหรับ backend ที่สร้างโดยเครื่องมือ คุณยังต้องออกแบบ RLS อย่างตั้งใจ แทนที่จะ “โรยมันทีหลัง”)
RLS ง่ายที่สุดเมื่อสคีมาของคุณบอกชัดว่าใครเป็นเจ้าของอะไร หากคุณเริ่มด้วยโมเดลที่ไม่ชัดและพยายาม “แก้ด้วยนโยบาย” มักได้คำสั่งช้าและบั๊กที่สับสน
เลือกคีย์ tenant ตัวเดียว (เช่น org_id) และใช้ให้สม่ำเสมอ ตารางที่เป็นของ tenant ควรมีคอลัมน์นี้ แม้จะอ้างถึงตารางอื่นที่มีคีย์เดียวกันด้วยก็ตาม สิ่งนี้หลีกเลี่ยงการ join ภายในนโยบายและทำให้การตรวจสอบ USING ง่าย
กฎปฏิบัติ: ถ้าแถวควรหายไปเมื่อผู้ใช้ยกเลิก ลูกค้า ควรมี org_id
นโยบาย RLS มักตอบคำถามเดียว: “ผู้ใช้นี้เป็นสมาชิกของ org นี้หรือไม่ และทำอะไรได้บ้าง?” ยากที่จะอนุมานจากคอลัมน์กระจัดกระจาย
เก็บตารางหลักให้เล็กและธรรมดา:
users (หนึ่งแถวต่อคน)orgs (หนึ่งแถวต่อ tenant)org_memberships (user_id, org_id, role, status)project_memberships สำหรับการเข้าถึงระดับโปรเจกต์เมื่อมีสิ่งนี้ นโยบายของคุณสามารถเช็กสมาชิกด้วยการค้นหาที่มีดัชนีได้ครั้งเดียว
ไม่ใช่ทุกอย่างต้องมี org_id ตารางอ้างอิงเช่น ประเทศ ประเภทสินค้า หรือประเภทแผนมักจะแชร์ระหว่าง tenant ทำเป็นอ่านอย่างเดียวสำหรับบทบาทส่วนใหญ่ และอย่าผูกมันกับ org เดียว
ข้อมูลที่เป็นของ tenant (projects, invoices, tickets) ควรหลีกเลี่ยงการดึงรายละเอียดเฉพาะ tenant ผ่านตารางที่แชร์ เก็บตารางแชร์ให้น้อยและเสถียร
Foreign keys ยังคงทำงานกับ RLS แต่การลบอาจเซอร์ไพรส์ถ้าบทบาทที่ลบไม่สามารถ “เห็น” แถวที่ขึ้นต่อกันได้ วางแผน cascades ให้ดีและทดสอบการลบจริง
เพิ่มดัชนีคอลัมน์ที่นโยบายกรองบ่อย โดยเฉพาะ org_id และคีย์การเป็นสมาชิก นโยบายที่อ่านว่า WHERE org_id = ... ไม่ควรกลายเป็นการสแกนทั้งตารางเมื่อขนาดตารางเป็นล้านแถว
RLS เป็นสวิตช์ต่อ-ตาราง เมื่อติดตั้งแล้ว PostgreSQL จะไม่ไว้ใจโค้ดแอปให้จำตัวกรอง tenant อีกต่อไป ทุก SELECT, UPDATE, และ DELETE จะถูกกรองด้วยนโยบาย และทุก INSERT และ UPDATE จะถูกตรวจสอบโดยนโยบาย
การเปลี่ยนความคิดครั้งใหญ่: เมื่อเปิด RLS แล้ว คำสั่งที่เคยคืนข้อมูลอาจเริ่มคืนเป็นศูนย์แถวโดยไม่เกิดข้อผิดพลาด นั่นคือตัวควบคุมการเข้าถึงของ PostgreSQL
นโยบายเป็นกฎเล็ก ๆ แนบกับตาราง พวกมันใช้การตรวจสองแบบ:
USING เป็นตัวกรองอ่าน ถ้าแถวไม่ตรง USING มันจะมองไม่เห็นสำหรับ SELECT และไม่สามารถเป็นเป้าของ UPDATE หรือ DELETEWITH CHECK เป็นประตูเขียน ตัดสินว่าแถวใหม่หรือแถวที่เปลี่ยนแล้วอนุญาตสำหรับ INSERT หรือ UPDATE หรือไม่แพตเทิร์น SaaS ทั่วไป: USING รับรองว่าคุณเห็นเฉพาะแถวจาก tenant ของคุณ และ WITH CHECK รับรองว่าคุณไม่สามารถแทรกแถวเข้า tenant อื่นโดยเดา tenant_id
เมื่อคุณเพิ่มนโยบายทีหลัง เรื่องนี้สำคัญ:
PERMISSIVE (ค่าดีฟอลต์): แถวอนุญาตถ้านโยบายใดนโยบายหนึ่งอนุญาตRESTRICTIVE: แถวจะอนุญาตก็ต่อเมื่อทุกนโยบายแบบ restrictive อนุญาตมัน (ทับพฤติกรรม permissive)ถ้าคุณวางแผนจะเรียงกฎเช่นการจับคู่ tenant บวกการเช็กบทบาทบวกการเป็นสมาชิกโปรเจกต์ นโยบาย restrictive อาจทำให้เจตนาแน่ชัด แต่ก็ทำให้ล็อกตัวเองออกได้ง่ายถ้าลืมเงื่อนไขตัวใดตัวหนึ่ง
RLS ต้องการค่าตัวตนที่เชื่อถือได้ ตัวเลือกทั่วไป:
app.user_id และ app.tenant_id)SET ROLE ... ต่อคำร้อง) ซึ่งทำงานได้แต่เพิ่มภาระการปฏิบัติการเลือกวิธีหนึ่งแล้วใช้ให้ทั่ว อย่าผสมแหล่งตัวตนข้ามบริการเป็นเส้นทางด่วนสู่บั๊กที่สับสน
ใช้คอนเวนชันที่คาดเดาได้เพื่อให้การ dump สคีมาและโลกรอ่านง่าย เช่น: {table}__{action}__{rule} เช่น projects__select__tenant_match
ถ้าคุณใหม่กับ RLS ให้เริ่มจากตารางเดียวและทำ proof ขนาดเล็ก เป้าหมายไม่ใช่ครอบคลุมทั้งหมด เป้าหมายคือให้ฐานข้อมูลปฏิเสธการเข้าถึงข้าม tenant แม้จะมีบั๊กในแอป
สมมติ projects ง่าย ๆ ก่อน เพิ่ม tenant_id ในทางที่ไม่ทำให้เขียนล้ม
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
ถัดไป แยกความเป็นเจ้าของจากการเข้าถึง แพตเทิร์นทั่วไปคือ: role หนึ่งเป็นเจ้าของตาราง (app_owner), อีก role ใช้โดย API (app_user) บทบาท API ไม่ควรเป็นเจ้าของตาราง มิฉะนั้นมันจะบายพาสนโยบายได้
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
ตอนนี้ตัดสินใจว่าคำร้องบอก Postgres ว่ากำลังให้บริการ tenant ไหนอย่างไร วิธีง่าย ๆ คือการตั้งค่าที่มีขอบเขตคำร้อง แอปของคุณตั้งค่านั้นทันทีหลังเปิดทรานแซกชัน
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
เปิด RLS และเริ่มด้วยการเข้าถึงแบบอ่าน
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
พิสูจน์ว่ามันทำงานโดยลองสอง tenant ต่างกันและตรวจดูการนับแถว
WITH CHECK)นโยบายอ่านไม่ปกป้องการเขียน เพิ่ม WITH CHECK เพื่อไม่ให้ insert และ update ลอบป้อนแถวไป tenant ผิด
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
วิธีรวดเร็วในการยืนยันพฤติกรรม (รวมถึงความล้มเหลว) คือเก็บสคริปต์ SQL เล็ก ๆ ที่คุณรันซ้ำหลังมิเกรชันทุกครั้ง:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (จะต้องล้ม)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (จะต้องล้ม)ROLLBACK;ถ้าคุณรันสคริปต์นั้นแล้วได้ผลลัพธ์เดิมทุกครั้ง คุณมีเบสไลน์ที่เชื่อถือได้ก่อนขยาย RLS ไปยังตารางอื่น
ทีมส่วนใหญ่หันมาใช้ RLS หลังจากเบื่อการเขียนการเช็กสิทธิ์ซ้ำ ๆ ในทุกคำสั่ง ข่าวดีคือรูปทรงนโยบายที่คุณต้องการมักคงที่
บางตารางเป็นของผู้ใช้คนเดียวตามธรรมชาติ (notes, API tokens) ขณะที่อื่น ๆ เป็นของ tenant ที่การเข้าถึงขึ้นกับการเป็นสมาชิก ปฏิบัติต่อสองกรณีนี้ต่างกัน
สำหรับข้อมูลที่เป็นของเจ้าของ นโยบายมักเช็ก created_by = app_user_id() สำหรับข้อมูลของ tenant นโยบายมักเช็กว่าผู้ใช้มีแถวสมาชิกสำหรับ org หรือไม่
วิธีปฏิบัติที่อ่านง่ายคือรวมตัวตนไว้ใน helper SQL เล็ก ๆ แล้วนำกลับมาใช้ใหม่:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
การอ่านมักกว้างกว่าการเขียน ตัวอย่าง: สมาชิกทุกคนของ org อาจ SELECT projects ได้ แต่เฉพาะ editor เท่านั้นที่ UPDATE และเฉพาะ owner เท่านั้นที่ DELETE
ทำให้ชัดเจน: นโยบายหนึ่งสำหรับ SELECT (membership), นโยบายหนึ่งสำหรับ INSERT/UPDATE พร้อม WITH CHECK (ตามบทบาท), และนโยบายหนึ่งสำหรับ DELETE (มักเข้มงวดกว่าการอัปเดต)
หลีกเลี่ยงการ “ปิด RLS สำหรับแอดมิน” แทนที่จะทำแบบนั้น ให้เพิ่มทางหนีภายในนโยบาย เช่น app_is_admin() เพื่อไม่ให้คุณเผลอให้สิทธิ์กว้างกับ role บริการร่วม
ถ้าคุณใช้ deleted_at หรือ status ให้ใส่มันเข้าในนโยบาย SELECT (deleted_at is null) มิฉะนั้นคนจะสามารถ “ฟื้น” แถวโดยพลิกค่าสถานะที่แอปคิดว่าเป็นขั้นสุดท้ายได้
WITH CHECK เป็นมิตรINSERT ... ON CONFLICT DO UPDATE ต้องผ่าน WITH CHECK สำหรับแถวหลังการเขียน หากนโยบายของคุณต้องการ created_by = app_user_id() ให้แน่ใจว่า upsert ตั้ง created_by เมื่อ insert และไม่เขียนทับเมื่อ update
ถ้าคุณสร้างโค้ด backend อัตโนมัติ แพตเทิร์นเหล่านี้ควรเป็นเทมเพลตภายในเพื่อให้ตารางใหม่เริ่มด้วยการตั้งค่าปลอดภัยแทนสเตตเปิดเปล่า
RLS ดีจนกว่าจะมีรายละเอียดเล็ก ๆ ที่ทำให้ดูเหมือน PostgreSQL "ซ่อนหรือโชว์ข้อมูลแบบสุ่ม" ข้อผิดพลาดด้านล่างคือสิ่งที่เสียเวลามากที่สุด
กับดักแรกคือการลืม WITH CHECK บน insert และ update USING ควบคุมสิ่งที่คุณมองเห็น ไม่ใช่สิ่งที่คุณอนุญาตให้สร้าง หากไม่มี WITH CHECK บั๊กในแอปอาจเขียนแถวเข้า tenant ผิด และคุณอาจไม่สังเกตเพราะผู้ใช้เดียวกันอ่านมันไม่ได้
กับดักรั่วทั่วไปอีกอย่างคือ “leaky join” คุณกรอง projects ถูกต้อง แล้ว join กับ invoices, notes, หรือ files ที่ไม่ได้ป้องกันแบบเดียวกัน การแก้ไขเข้มงวดแต่เรียบง่าย: ทุกตารางที่อาจเปิดเผยข้อมูล tenant ต้องมีนโยบายของตัวเอง และ view ไม่ควรพึ่งพาตารางเดียวที่ปลอดภัยเท่านั้น
รูปแบบความล้มเหลวยอดนิยมปรากฏเร็ว:
WITH CHECKนโยบายที่อ้างถึงตารางเดียวกัน (โดยตรงหรือผ่าน view) อาจสร้างการเรียกซ้ำที่น่าประหลาดใจ นโยบายอาจเช็กสมาชิกโดยการคิวรี view ที่อ่านตารางที่ป้องกันอีกครั้ง ซึ่งนำไปสู่ข้อผิดพลาด คำสั่งช้า หรือแม้แต่นโยบายที่ไม่เคยแมตช์
การตั้งค่า role เป็นอีกแหล่งความสับสน เจ้าของตารางและ role ที่ยกระดับสิทธิ์สามารถบายพาส RLS ทำให้เทสต์ของคุณผ่านแต่ผู้ใช้จริงล้ม (หรือกลับกัน) ทดสอบด้วย role ที่มีสิทธิ์ต่ำเดียวกับที่แอปใช้เสมอ
ระวัง SECURITY DEFINER ฟังก์ชัน พวกมันรันด้วยสิทธิ์ของเจ้าของฟังก์ชัน ดังนั้น helper อย่าง current_tenant_id() อาจโอเค แต่ฟังก์ชัน “สะดวก” ที่อ่านข้อมูลอาจอ่านข้าม tenant โดยไม่ตั้งใจ เว้นวางการตั้งค่า search_path ที่ปลอดภัยภายในฟังก์ชัน security definer ด้วย มิฉะนั้นฟังก์ชันอาจใช้วัตถุชื่อเดียวกันจาก schema อื่นและตรรกะนโยบายของคุณอาจชี้ไปยังสิ่งที่ผิดตามสถานะเซสชัน
บั๊ก RLS มักเกิดจากบริบทที่ขาดหาย ไม่ใช่ "SQL ผิด" นโยบายอาจถูกต้องในทางทฤษฎีแต่ล้มเพราะ role เซสชันต่างจากที่คุณคิด หรือเพราะคำร้องไม่เคยตั้งค่า tenant และ user ที่นโยบายพึ่งพา
วิธีที่เชื่อถือได้ในการทำสำเนารายงานโปรดักชันคือจำลองการตั้งค่าเซสชันเดียวกันในเครื่องและรันคำสั่ง SQL เดียวกัน นั่นมักหมายถึง:
SET ROLE app_user; (หรือ role API จริง)SELECT set_config('app.tenant_id', 't_123', true); และ SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);เมื่อไม่แน่ใจว่านโยบายใดถูกนำมาใช้ ให้ตรวจสอบแค็ตตาล็อกแทนการเดา pg_policies แสดงแต่ละนโยบาย คำสั่ง และนิพจน์ USING กับ WITH CHECK จับคู่กับ pg_class เพื่อตรวจว่า RLS เปิดอยู่บนตารางและไม่ได้ถูกบายพาส
ปัญหาด้านประสิทธิภาพสามารถดูเหมือนปัญหา auth ได้ นโยบายที่ join ตารางสมาชิกหรือเรียกฟังก์ชันอาจถูกต้องแต่ช้าเมื่อโตขึ้น ใช้ EXPLAIN (ANALYZE, BUFFERS) บนคำสั่งที่ทำซ้ำและมองหา sequential scans, nested loops ที่คาดไม่ถึง หรือกรองที่ถูกใช้ช้า ดัชนีที่หายไปบน (tenant_id, user_id) และตารางสมาชิกเป็นสาเหตุทั่วไป
ยังช่วยได้ถ้าล็อกค่าสามอย่างต่อคำร้องที่ชั้นแอป: tenant ID, user ID, และ role ฐานข้อมูลที่ใช้สำหรับคำร้อง เมื่อค่าพวกนี้ไม่ตรงกับที่คุณคิด RLS จะทำงาน “ผิด” เพราะอินพุตผิด
สำหรับเทสต์ ให้เก็บ tenant ตัวอย่างไม่กี่ตัวและทำให้ความล้มเหลวชัดเจน ชุดเทสต์เล็ก ๆ ควรรวม: “Tenant A ไม่สามารถอ่าน Tenant B”, “ผู้ใช้ที่ไม่เป็นสมาชิกไม่เห็นโปรเจกต์”, “owner อัปเดตได้ viewer ไม่ได้”, “insert ถูกบล็อกถ้า tenant_id ไม่ตรงบริบท”, และ “admin override ใช้ได้เฉพาะที่ตั้งใจไว้”
ถือ RLS เสมือนเข็มขัดนิรภัย ไม่ใช่สวิตช์ฟีเจอร์ พลาดเล็กน้อยกลายเป็น “ทุกคนเห็นข้อมูลทุกคน” หรือ “ทุกอย่างคืน 0 แถว”
ตรวจสอบให้แน่ใจว่าการออกแบบตารางและกฎนโยบายสอดคล้องกับโมเดล tenant ของคุณ
tenant_id) ถ้าไม่มี ให้จดเหตุผลไว้ (เช่น ตารางอ้างอิงระดับโลก)FORCE ROW LEVEL SECURITY บนตารางเหล่านั้นUSING Writes ต้องมี WITH CHECK เพื่อไม่ให้ insert และ update ย้ายแถวไป tenant อื่นได้tenant_id หรือ join ผ่านตารางสมาชิก ให้เพิ่มดัชนีที่ตรงกันสถานการณ์ความสมเหตุสมผลง่าย ๆ: ผู้ใช้ tenant A อ่าน invoice ของตัวเองได้, สามารถ insert invoice ได้เฉพาะสำหรับ tenant A, และไม่สามารถอัปเดต invoice เพื่อเปลี่ยน tenant_id
RLS แข็งแรงเท่ากับบทบาทที่แอปใช้
bypassrlsจินตนาการแอป B2B ที่บริษัท (orgs) มี projects และ projects มี tasks ผู้ใช้สามารถเป็นสมาชิกหลาย org และผู้ใช้อาจเป็นสมาชิกของบางโปรเจกต์เท่านั้น นี่เป็นกรณีที่เหมาะกับ RLS เพราะฐานข้อมูลสามารถบังคับการแยก tenant แม้ endpoint ลืมตัวกรอง
โมเดลง่าย ๆ คือ: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...) org_id บน tasks นั้นตั้งใจให้มี มันทำให้นโยบายเรียบง่ายและลดความประหลาดใจขณะ join
การรั่วคลาสสิกเกิดเมื่อ tasks มีแค่ project_id และนโยบายเช็กการเข้าถึงผ่าน join ไปยัง projects ความผิดพลาดหนึ่งครั้ง (นโยบาย permissive บน projects, join ที่ลดเงื่อนไข, หรือ view ที่เปลี่ยนบริบท) อาจเปิดเผย tasks ของ org อื่น
เส้นทางการมิเกรชที่ปลอดภัยหลีกเลี่ยงการทำลายทราฟฟิกโปรดักชัน:
org_id ให้ tasks, เพิ่มตารางสมาชิก)tasks.org_id จาก projects.org_id, แล้วเพิ่ม NOT NULLการสนับสนุนควรจัดการด้วย role แคบ ๆ สำหรับฉุกเฉิน ไม่ใช่การปิด RLS เก็บแยกจากบัญชีสนับสนุนปกติและบันทึกชัดเมื่อมันถูกใช้
จดบันทึกกฎเพื่อไม่ให้นโยบายไหลเบี้ยว: ตัวแปรเซสชันใดต้องตั้ง (user_id, org_id), ตารางใดต้องมี org_id, คำว่า “member” หมายถึงอะไร, และตัวอย่าง SQL สั้น ๆ ที่ควรคืน 0 แถวเมื่อรันด้วย org ที่ไม่ถูกต้อง
RLS ใช้ง่ายเมื่อคุณปฏิบัติต่อมันเหมือนการเปลี่ยนแปลงผลิตภัณฑ์ ปล่อยเป็นชิ้นเล็ก ๆ พิสูจน์พฤติกรรมด้วยเทสต์ และเก็บบันทึกเหตุผลว่าทำไมนโยบายแต่ละข้อถึงมีอยู่
แผนการนำขึ้นที่มักได้ผล:
projects) และล็อกมันหลังจากตารางแรกเสถียร ทำให้การเปลี่ยนนโยบายเป็นเรื่องจงใจ เพิ่มขั้นตอนตรวจนโยบายในการมิเกรชัน และใส่บันทึกสั้น ๆ เกี่ยวกับเจตนา (ใครควรเข้าถึงอะไรและทำไม) พร้อมกับการอัปเดตเทสต์ที่สอดคล้อง สิ่งนี้ป้องกันการเพิ่ม OR แบบไม่คิดจนสุดท้ายกลายเป็นรูรั่ว
ถ้าคุณเร่งรีบ เครื่องมืออย่าง Koder.ai (koder.ai) สามารถช่วยสร้างจุดเริ่มต้น Go + PostgreSQL ผ่านการคุย แล้วคุณค่อยวางนโยบาย RLS และเทสต์ด้วยวินัยเดียวกับ backend ที่เขียนมือ
สุดท้าย ให้เก็บราวนิรภัยช่วง rollout: ถ่าย snapshot ก่อนมิเกรชันนโยบาย ฝึกการย้อนกลับจนชิน และมีทางฉุกเฉินสำหรับการสนับสนุนที่ไม่ปิด RLS ทั้งระบบ
RLS ทำให้ PostgreSQL บังคับว่าแถวใดมองเห็นหรือแก้ไขได้สำหรับคำร้องหนึ่ง ๆ ดังนั้นการแยก tenant ไม่ต้องพึ่งพาว่า endpoint ทุกตัวจะจำต้องมีเงื่อนไข WHERE tenant_id = ... ชัยชนะหลักคือการลดข้อผิดพลาดแบบ “ลืมใส่เงื่อนไข” เมื่อแอปของคุณโตและคำสั่ง SQL เพิ่มขึ้น
คุ้มเมื่อกฎการเข้าถึงสม่ำเสมอและอิงแถว เช่น การแยก tenant หรือการเข้าถึงตามสมาชิก และเมื่อคุณมีหลายเส้นทางการดึงข้อมูล (การค้นหา, การส่งออก, หน้าผู้ดูแล, งานแบ็คกราวด์) มันมักไม่คุ้มถ้ากฎส่วนใหญ่เป็นระดับคอลัมน์ มีข้อยกเว้นมาก หรือเน้นการรายงานข้าม tenant ขนาดใหญ่
ใช้ RLS สำหรับการมองเห็นแถวและการควบคุมการเขียนขั้นพื้นฐาน ส่วนความเป็นส่วนตัวของคอลัมน์มักต้องใช้ views และสิทธิ์คอลัมน์ และกฎทางธุรกิจที่ซับซ้อน (เช่น ความเป็นเจ้าของการเรียกเก็บเงินหรือกระบวนการอนุมัติ) ยังคงเหมาะกับตรรกะในแอปหรือข้อจำกัดในฐานข้อมูลที่ออกแบบมาอย่างระมัดระวัง
สร้าง role ที่มีสิทธิ์น้อยสำหรับ API (ไม่ใช่เจ้าของตาราง) เปิด RLS แล้วเพิ่มนโยบาย SELECT และนโยบาย INSERT/UPDATE ที่มี WITH CHECK ตั้งค่าสถานะในเซสชันต่อคำร้อง (เช่น app.current_tenant) แล้วยืนยันว่าการสลับค่านั้นเปลี่ยนแถวที่คุณเห็นและเขียนได้
วิธีที่ใช้บ่อยคือเก็บค่าในตัวแปรเซสชันต่อคำร้อง ตั้งค่าตอนเริ่มทรานแซกชัน เช่น app.tenant_id และ app.user_id กุญแจคือความสม่ำเสมอ: ทุกเส้นทางโค้ด (เว็บ, งานแบ็คกราวด์, สคริปต์) ต้องตั้งค่าสิ่งเดียวกันที่นโยบายคาดหวัง มิฉะนั้นคุณจะเจอพฤติกรรม “คืนค่าเป็น 0 แถว” ที่สับสน
USING ควบคุมตัวกรองอ่าน: แถวที่ไม่ตรงเงื่อนไขจะมองไม่เห็นสำหรับ SELECT และไม่สามารถเป็นเป้าหมายของ UPDATE/DELETE ได้ ส่วน WITH CHECK เป็นประตูเขียน: กำหนดว่าแถวใหม่หรือแถวที่เปลี่ยนแล้วอนุญาตสำหรับ INSERT หรือ UPDATE หรือไม่
เพราะถ้าคุณเพิ่มแค่นโยบาย USING จุดบกพร่องของ endpoint ยังอาจแทรกหรืออัปเดตแถวเข้า tenant ผิดได้ และผู้ใช้เดียวกันอาจอ่านแถวผิดนั้นไม่ได้เลยจนไม่สังเกต ดังนั้นให้จับคู่กฎอ่านกับ WITH CHECK สำหรับการเขียนเสมอเพื่อป้องกันการสร้างข้อมูลผิดตั้งแต่ต้น
หลีกเลี่ยงการ join ภายในนโยบายโดยใส่คีย์ tenant (เช่น org_id) ลงในตารางที่เป็นของ tenant โดยตรง แม้มันจะอ้างถึงตารางอื่นที่มี org_id ก็ตาม เพิ่มตารางสมาชิก (org_memberships, และถ้าจำเป็น project_memberships) เพื่อให้นโยบายสามารถตรวจสอบด้วยการค้นหาที่มีดัชนีเดียวแทนการอนุมานซับซ้อน
ก่อนอื่นจำลองสภาพแวดล้อมเซสชันเดียวกับที่แอปใช้ โดยตั้ง role และตัวแปรเซสชันเดียวกัน แล้วรันคำสั่ง SQL เดิมที่แอปรัน ต่อมายืนยันว่า RLS ถูกเปิดและตรวจดู pg_policies เพื่อดู USING และ WITH CHECK ที่ใช้งาน เพราะข้อผิดพลาดของ RLS มักเกิดจากบริบทตัวตนที่ขาดหาย มากกว่าที่จะเป็น “SQL ผิด”
ใช่ แต่ถือโค้ดที่สร้างขึ้นเป็นจุดเริ่มต้น ไม่ใช่ระบบความปลอดภัยสำเร็จรูป ถ้าคุณใช้ Koder.ai เพื่อสร้าง Go + PostgreSQL backend คุณยังต้องกำหนดโมเดล tenant ตั้งค่าตัวตนในเซสชันอย่างสม่ำเสมอ และเพิ่มนโยบายและเทสต์อย่างตั้งใจ เพื่อไม่ให้ตารางใหม่ถูกส่งขึ้นโดยไม่มีการป้องกันที่เหมาะสม