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

สถาปัตยกรรมของ Supabase

ทุก request ที่เข้ามาที่ auto-generated API ของ Supabase จะไปถึง Postgres พร้อมพก Postgres role ตัวใดตัวหนึ่งเสมอ — anon, authenticated หรือ service_role — และ role นี้แหละคือแกนหลักทางสถาปัตยกรรมที่ทุกอย่างอื่น (Data API, Row Level Security) สร้างต่อยอดขึ้นมา

auto-generated API ของ Supabase authenticate ทุก request หรือไม่ก็ปฏิบัติกับ request นั้นแบบ anonymous อย่างชัดเจน ไม่ว่าจะเป็นแบบไหน request จะไปถึง Postgres ในฐานะหนึ่งในสาม role นี้

  • anon — request ที่ไม่ได้ authenticate ไม่มี user login อยู่
  • authenticated — request จาก user ที่ login แล้ว พก JWT claim มาด้วย รวมถึง auth.uid() สำหรับระบุว่าเป็น user คนไหน
  • service_role — server-side code ที่เชื่อถือได้ role นี้ bypass Row Level Security ทั้งหมด เป็น key ตัวเดียวกับที่คุณเจอในบทก่อนหน้า
-- Inside a Row Level Security policy, this is exactly how you tell roles apart.
create policy "Users can view their own profile"
on public.profiles
for select
to authenticated
using (auth.uid() = id);

พิสูจน์ด้วย RPC function จริง เรียกผ่าน Data API ได้ ที่แค่ report กลับมาว่า role ไหนเป็นคนรันมัน:

-- sql/fnd_whoami.sql (already applied — reference only, not re-collected by the harness)
create or replace function public.fnd_whoami()
returns table(who text, pg_role text)
language sql
security invoker
as $$
select current_user::text, current_setting('role', true)::text;
$$;
grant execute on function public.fnd_whoami() to anon, authenticated;
$ curl -s -X POST "$API_URL/rest/v1/rpc/fnd_whoami" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY" -H "Content-Type: application/json" -d '{}'
[{"who":"anon","pg_role":"anon"}]
$ curl -s -X POST "$API_URL/rest/v1/rpc/fnd_whoami" -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" -H "Content-Type: application/json" -d '{}'
[{"who":"service_role","pg_role":"service_role"}]

function เดียวกัน connection pool เดียวกันข้างใน แต่ current_user เองเปลี่ยนไปตาม key ที่เซ็น request — ไม่ใช่ header ที่ PostgREST แค่อ่านแล้วเชื่อ แต่คือ Postgres session role จริงที่ query รันในฐานะนั้นจริง ๆ

PostgREST คือ service ที่แปล HTTP request แบบ GET /rest/v1/profiles?select=* ให้กลายเป็น SQL query จริง แล้วรัน query นั้น ในฐานะ role ที่ request นั้นพกมา จุดนี้สำคัญกว่าที่ดูเผิน ๆ PostgREST ไม่ได้ implement authorization system แยกของตัวเองเลย แต่พึ่งพา access control ของ Postgres ทั้งหมด — permission แบบ grant/revoke ธรรมดา และ policy Row Level Security — เพื่อตัดสินว่า role นั้นทำอะไรได้จริงบ้าง access control อยู่ที่ database ไม่ใช่ที่ API layer ที่อยู่ด้านหน้า

// This client call becomes an HTTP request to PostgREST, executed as
// whatever role the current session carries (anon or authenticated).
const { data, error } = await supabase
.from('profiles')
.select('id, username')
.eq('id', userId);

Auth, Storage และ Realtime เป็น service แยก (process แยก) แต่ไม่มีตัวไหนสร้าง storage layer ของตัวเองขึ้นมาใหม่เลย ทุกตัวสุดท้ายอ่านเขียน Postgres database เดียวกันนี้

  • Auth (GoTrue) จัดการ schema auth — auth.users, session และ table ที่เกี่ยวข้อง
  • Storage จัดการ metadata table ของตัวเอง (bucket, object, permission) ใน schema storage ตัวไฟล์จริงอยู่ใน object store แต่ access rule เป็น row ใน Postgres
  • Realtime อ่าน write-ahead log (WAL) ของ Postgres เพื่อ stream การเปลี่ยนแปลงของ row นอกเหนือจากการมี broadcast/presence channel แบบ ephemeral ให้ใช้
flowchart LR
  client["Client request (anon / authenticated / service_role)"] --> postgrest["PostgREST"]
  postgrest -->|"executes SQL as that role"| pg[("Postgres<br/>(grants + RLS enforced here)")]
  auth["Auth (GoTrue)"] --> pg
  storage["Storage"] --> pg
  realtime["Realtime (reads WAL)"] --> pg
role ของ request เดินทางผ่าน PostgREST เข้า Postgres service อื่นใช้ database เดียวกัน

AI assistant ถูกขอให้ “add a check that only admins can call this function” แล้วสร้างสิ่งนี้:

🐛 หา bug ในโค้ดนี้

-- An AI assistant's attempt at an "admin-only" RPC guard
create or replace function public.fnd_admin_report()
returns table(total bigint)
language plpgsql
security definer
as $$
begin
if current_user != 'service_role' then
raise exception 'admins only';
end if;
return query select count(*) from public.fnd_widgets;
end;
$$;
เฉลย

สิ่งนี้ดูเหมือน guard จริง และมันจะทำงานได้จริงถ้าถูกเรียกด้วย service_role key ตรง ๆ กับ Postgres เท่านั้น — แต่มันถูกเขียนมาเพื่อ expose ผ่าน Data API เป็น RPC function ซึ่งทุก request ที่มาถึงมันผ่าน PostgREST มาในฐานะ role บางอย่าง อยู่แล้ว บั๊กคือ: security definer ทำให้ function นี้รันด้วยสิทธิ์ของ owner ของ function (ปกติคือ postgres) ซึ่ง proof จาก RPC request ข้างบนพิสูจน์ไปแล้วว่า current_user สะท้อน role ของ request ที่เรียก ไม่ใช่ owner — ดังนั้น check นี้เทียบผิดตัวไปเลยสำหรับ call ส่วนใหญ่ และแย่กว่านั้น อันตรายจริงของ function security definer (บท RPC ข้าง ๆ บทนี้พูดถึงลึกกว่านี้) คือมันต้องการ set search_path = '' เพื่อกัน schema-injection ซึ่งก็ขาดไปด้วยเช่นกัน ทางแก้ที่ตรงกับสถาปัตยกรรมของคอร์สนี้จริง ๆ: อย่าพยายามเช็ค current_user ข้างใน function security definer เลย — ไม่ว่าจะถอด security definer ออก (default เป็น security invoker แล้วให้ RLS กับ grant บน fnd_widgets ตัดสินสิทธิ์แบบปกติ) หรือถ้าจำเป็นต้องมีสิทธิ์ยกระดับจริง ๆ ให้เช็ค identity จริงของผู้เรียกผ่าน auth.uid() เทียบกับ flag profiles.is_admin (pattern ที่ module Edge Functions พิสูจน์ไว้) ไม่ใช่เทียบ current_user กับชื่อ role

มองทุก request ที่เข้า Data API ว่ามาถึง Postgres พร้อม name tag ติดตัวอยู่แล้ว — Postgres role — และทุกอย่างข้างล่าง (grant, RLS policy) อ่าน name tag นั้น ไม่ใช่ header หรือ body ของ request งานเดียวของ PostgREST คือเลือกว่าจะติด name tag ไหน (anon, authenticated หรือ service_role ตาม JWT ที่ได้รับมา) แล้วก็หลีกทางออกไป proof ข้างบนแสดงว่า name tag นี้ไม่ใช่เรื่องสมมติที่ PostgREST แค่จำไว้ในใจ แต่คือ current_user จริงที่ Postgres เองเห็นตลอด request นั้น

  • การบังคับ access control ไว้ใน Postgres ทั้งหมด (grant + RLS) ทำให้ client ทุกตัว — REST, GraphQL, client ในอนาคตที่คอร์สนี้ยังไม่ได้พูดถึง — ได้ guarantee เดียวกันอัตโนมัติ — แลกมาด้วยต้องคิดแบบ role กับ policy แทนที่จะเป็น check แบบ “if (user.isAdmin)” ที่คุ้นเคยกว่า พิสูจน์ข้างบนแล้วว่าพังพอดีแบบนั้นเมื่อพอร์ตแบบไม่ระวังเข้าไปใน function security definer
  • security definer ให้ function ทำอะไรบางอย่างที่ role ของผู้เรียกเองทำไม่ได้โดยตรง (pattern admin-report แบบที่ทำถูก) — แลกมาด้วย function เองกลายเป็น access-control boundary ทั้งหมด เพราะ Postgres ไม่ apply ข้อจำกัดของผู้เรียกเองข้างในมันเลย
  • PostgREST แปล HTTP เป็น SQL ตรง ๆ ทำให้ API surface sync กับ schema อัตโนมัติ — แลกมาด้วย grant และ RLS ของ schema เองเป็นสิ่งเดียวที่กั้นระหว่าง “reachable” กับ “secure” ไม่มี application-layer authorization code สำรองไว้เผื่อ rule ระดับ database ผิด
  • “มีแค่ anon กับ authenticated ที่สำคัญจริง — service_role แค่ convenience สำหรับ script” proof whoami ข้างบนปฏิบัติกับมันเหมือนกันทุกอย่าง: มันคือ Postgres role จริงเหมือนอีกสองตัว แค่มี attribute bypassrls เพิ่มมา ไม่มีอะไร special-case นอกจาก attribute นั้นตัวเดียว
  • “function security definer รู้อัตโนมัติว่าผู้เรียกตัวจริงคือใคร” ถูกหักล้างด้วย SpotTheBug ข้างบน: current_user ข้างใน function security definer สะท้อน owner ของ function ไม่ใช่ผู้เรียก ซึ่งเป็นเหตุผลที่เช็ค current_user เพื่อ authorization ข้างในมันกลับตาลปัตรเลย
  • “PostgREST มีระบบ user/session ของตัวเองแยกจาก Postgres” ไม่มี session store แยกเลย — พิสูจน์ข้างบนแล้วว่ากลไก authorization ทั้งหมดของ PostgREST คือการเลือกว่าจะรัน query ในฐานะ Postgres role ตัวไหน
request ที่ไม่ได้ authenticate ใช้ Postgres role ไหน
access control ของ auto-generated API ใน Supabase ถูก enforce อยู่ที่ไหนจริง ๆ
server-side code ที่เชื่อถือได้ใช้ role ไหนเพื่อ bypass Row Level Security
Auth, Storage และ Realtime เกี่ยวข้องกับ Postgres database ข้างล่างยังไง
RPC function ที่ AI สร้างเช็ค if current_user != 'service_role' then raise exception เพื่อบังคับ "admins only" แต่ function ประกาศเป็น security definer ทำไม check นี้ถึงไม่ทำสิ่งที่มันดูเหมือนจะทำ