ข้ามไปยังเนื้อหา

RLS Patterns and Pitfalls

RLS ในโลกจริงส่วนใหญ่คือการผสม pattern ไม่กี่แบบเข้าด้วยกัน — owner-only, public-read-owner-write, role-based — และ bug ของ RLS ในโลกจริงส่วนใหญ่ก็มาจากการลืม policy ของ operation ใด operation หนึ่ง หรือลืม index column ที่ policy ใช้กรอง

pattern จากบทที่แล้ว row จะมองเห็นหรือแก้ได้เฉพาะ user ที่เป็นเจ้าของเท่านั้น โดยใช้ auth.uid() = user_id ทั้งใน using และ with check

create policy "Owners can update their own rows"
on public.todos
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

รูปแบบที่พบบ่อยมากสำหรับ content แบบ blog post หรือ comment ใครก็อ่านได้ แต่แก้หรือลบได้เฉพาะเจ้าของ row เท่านั้น แบบนี้ต้องใช้ policy สองอันแยกกัน อันละทิศทาง เพราะ using (true) ของ select ไม่ได้บอกอะไรเลยว่าใครเขียนได้บ้าง

-- Anyone (including anon) can read
create policy "Anyone can read posts"
on public.posts
for select
using (true);
-- Only the owner can update their own post
create policy "Owners can update their posts"
on public.posts
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

บางครั้งสิทธิ์การเข้าถึงขึ้นกับ role ไม่ใช่ความเป็นเจ้าของ เช่น admin ที่ moderate content ของทุกคนได้ วิธีที่นิยมคือใช้ table user_roles แล้ว join เข้าไปใน policy condition (custom claim ที่ฝังตรงใน JWT ก็เป็นอีกทางเลือกหนึ่ง แต่ table แบบ join จัดการง่ายกว่าเพราะไม่ต้องออก token ใหม่ทุกครั้ง)

create policy "Admins can delete any post"
on public.posts
for delete
using (
exists (
select 1 from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.role = 'admin'
)
);

RLS ถูกประเมิน แยกตาม operationselect, insert, update, delete แต่ละอันเช็คกับ policy ของตัวเองแยกกันโดยอิสระ table หนึ่งใบจึงมี policy select ที่ใช้งานได้ปกติ แต่ไม่มี policy insert เลยก็ได้ง่าย ๆ ผลคือ อ่านได้ปกติ แต่ทุกการเขียนจะ fail แบบเงียบ ๆ ด้วย permission error ซึ่งดูเหมือน bug ของโค้ดฝั่งแอป ทั้งที่จริง ๆ แค่ลืม policy ก่อน deploy table ควรเช็ก operation ที่แอปต้องใช้กับ table นั้นทีละอัน แล้วให้แน่ใจว่ามี policy ครบทุกตัว

condition ของ policy รันกับทุก row ที่ Postgres พิจารณาใน query เหมือนกับ where clause เป๊ะ ถ้า column ที่ policy ใช้กรอง — ส่วนใหญ่คือ user_id ในตัวอย่างข้างบน — ไม่มี index query ที่ดูเรียบง่ายอาจกลายเป็น full sequential scan ทันทีที่ table มีข้อมูลจริงเยอะขึ้น ให้ index column ที่ RLS policy อ้างอิงเหมือนที่ทำกับ column ที่ถูก filter บ่อย ๆ ทั่วไป

create index on public.posts (user_id);

อีกจุดที่ควรรู้ไว้คือการห่อ auth.uid() ด้วย select ภายใน policy condition จะทำให้ Postgres ประเมินแค่ครั้งเดียวต่อ query แทนที่จะประเมินทุก row ซึ่งมีผลชัดเจนเมื่อ table มี row เยอะขึ้น

create policy "Owners can update their posts"
on public.posts
for update
using ((select auth.uid()) = user_id);

อย่าคิดเอาเองว่า policy ทำงานถูกต้อง ให้ test ในฐานะ user ที่ตั้งใจจะจำกัดสิทธิ์ table editor ของ Supabase Studio มี feature impersonation ไว้สำหรับเรื่องนี้โดยเฉพาะ หรือจะทำแบบเดียวกันตรง ๆ ใน SQL session ก็ได้ด้วยการสลับ role แล้วตั้ง claim ของ JWT ให้เหมือน request จริง

set role authenticated;
set request.jwt.claims = '{"sub": "11111111-1111-1111-1111-111111111111", "role": "authenticated"}';
select * from public.posts; -- now runs exactly as that user would see it
reset role;
flowchart TB
  subgraph missing["Table with only a SELECT policy"]
    m1["select policy: present"] --> m2["Reads work"]
    m3["insert policy: missing"] --> m4["Writes silently blocked"]
  end
  subgraph covered["Table with all four policies"]
    c1["select / insert / update / delete: all present"] --> c2["Reads and writes both work as intended"]
  end
A missing policy silently blocks one operation while others keep working
ทำไม public read, owner-only write ถึงต้องใช้ policy สองอันแยกกัน แทนที่จะใช้อันเดียว
pitfall เฉพาะของการลืม policy สำหรับ operation หนึ่งบน table คืออะไร
ทำไม policy condition ที่อ้างอิง column ไม่มี index ถึงทำร้าย performance เมื่อข้อมูลเยอะขึ้น
วิธีที่ใช้งานได้จริงในการตรวจสอบว่า RLS policy ทำงานตามที่ตั้งใจก่อน ship คืออะไร