Data & Systems
Backend ที่ทนทาน
Postgres, Supabase และ RLS: ที่ที่ข้อมูลอาศัยอยู่และใครเข้าถึงได้ ความปลอดภัยคืออำนาจของเซิร์ฟเวอร์ ไม่ใช่ของไคลเอนต์ นี่คือกระดูกสันหลังของ XHUB, Espejo และโรงเรียนแห่งนี้
DS-01 การออกแบบโมเดลข้อมูลใน Postgres
บทเรียน ออกแบบ schema ใน Postgres ด้วย table, key, type และความสัมพันธ์ที่สะท้อน invariant จริงของ domain และไม่บังคับให้คุณต้องทำ migration ที่เจ็บปวดในอีกสามสัปดาห์ถัดมา
การออกแบบโมเดลข้อมูลใน Postgres
บทเรียนออกแบบ schema ใน Postgres ด้วย table, key, type และความสัมพันธ์ที่สะท้อน invariant จริงของ domain และไม่บังคับให้คุณต้องทำ migration ที่เจ็บปวดในอีกสามสัปดาห์ถัดมา
โมเดลข้อมูลคือการตัดสินใจที่มีต้นทุนในการย้อนกลับแพงที่สุดใน stack ทั้งหมด โค้ดเขียนใหม่ได้ในบ่ายเดียว แต่ table ที่กำหนด type ผิดและมีข้อมูล 50,000 แถวอยู่บน production จะตามหลอกหลอนคุณไปอีกหลายเดือน ในฐานข้อมูลที่แชร์กันของ MOONKEY LAB (profiles, progress, proofs) type ที่หละหลวมหรือ FK ที่ลืมใส่คือรูรั่วของ integrity ที่ไม่มี RLS ตัวไหนมาอุดได้
บทเรียน
Postgres ไม่ใช่ "สเปรดชีตที่มี SQL" แต่เป็นเอนจินเชิงสัมพันธ์ที่มีระบบ type อย่างจริงจัง และงานแรกของคุณในฐานะ operator ด้านข้อมูลคือใช้ระบบ type นั้นให้สถานะที่เป็นไปไม่ได้กลายเป็นสิ่งที่แทนค่าไม่ได้โดยตรง ก่อนเขียน CREATE TABLE แม้แต่บรรทัดเดียว ให้ระบุ entity ของ domain และความสัมพันธ์ระหว่างกันให้ครบ ใน MOONKEY LAB entity จริงคือ ผู้ใช้ (profiles), ความก้าวหน้าตามโมดูล (progress), proof ที่อัปโหลด (proofs), feedback ที่ทิ้งไว้ (feedback) และ lead ที่เก็บมา แต่ละอย่างคือหนึ่ง table ความสัมพันธ์แบบ 1-ต่อ-N ระหว่างกันแต่ละคู่คือหนึ่ง foreign key อย่าเริ่มจาก column แต่ให้เริ่มจากลูกศรระหว่างกล่อง
ตัวระบุ (identifier) ใน Supabase table profiles ห้อยอยู่ใต้ auth.users ดังนั้น primary key ของมันจึงไม่ใช่ id ที่สร้างใหม่อัตโนมัติ แต่เป็น `id uuid primary key references auth.users(id) on delete cascade` นี่คือ pattern มาตรฐานของ Supabase และเป็นความตั้งใจ เพราะ uuid ของผู้ใช้ที่ผ่านการ authenticate แล้วคือ identity ทั่วทั้งแอปของคุณ และ `auth.uid()` (ที่คุณจะใช้ในทุก policy RLS) คืนค่า uuid นั้นพอดี สำหรับ table ลูกอย่าง progress หรือ proofs คุณใช้ key ของตัวเองได้ `id uuid primary key default gen_random_uuid()` บวกกับ column `user_id uuid not null references auth.users(id) on delete cascade` สังเกต `not null` และ `on delete cascade` ถ้าคุณลบผู้ใช้ proof ของเขาก็หายตามไปด้วย ถ้าคุณปล่อยให้ user_id เป็น null ได้ คุณจะมีแถวกำพร้าที่ไม่มี policy `user_id = auth.uid()` ตัวไหนจะแสดงหรือปกป้องได้
type มีความสำคัญและกำหนดถูกตั้งแต่ต้นก็ราคาถูก สำหรับ timestamp ให้ใช้ `timestamptz` (with time zone) เสมอ ไม่ใช่ `timestamp` เปล่าๆ โปรเจกต์ที่มีผู้ใช้อยู่ใน Chiang Mai, Mallorca และ Madrid รับความกำกวมเรื่องเขตเวลาไม่ได้ และ `timestamptz` จัดเก็บเป็น UTC แล้วแปลงค่าที่ขอบเขต สำหรับเงิน ให้ใช้ `numeric` ห้ามใช้ `float` เด็ดขาด (ทศนิยมฐานสองแทนค่า 0.10 ให้พอดีไม่ได้) สำหรับ field ที่มีชุดค่าปิด เช่น ระดับของ operator: monkey, gorilla, eagle คุณมีสองทางเลือกที่ตรงไปตรงมา คือ `enum` ของ Postgres หรือ `text` พร้อม `check (role in ('monkey','gorilla','eagle'))` ผมเลือก CHECK มากกว่า enum ในโปรเจกต์ที่ยังอ่อนวัย เพราะการเพิ่มค่าให้ enum ต้องใช้ `ALTER TYPE` และมี friction เชิง transaction ขณะที่การเปลี่ยน CHECK เป็นเพียง ALTER table ธรรมดา สำหรับข้อมูลกึ่งโครงสร้าง เช่น payload ของ proof หรือ metadata ที่ยืดหยุ่น ให้ใช้ `jsonb` ไม่ใช่ `json` เพราะ jsonb เป็น binary, สร้างดัชนีด้วย GIN ได้ และตัด key ซ้ำออก
default และ NOT NULL คือแนวป้องกันด่านแรกของคุณ มาก่อน RLS หรือ check ใดๆ ในแอป `created_at timestamptz not null default now()` หมายความว่าเป็นไปไม่ได้ที่จะ insert แถวโดยไม่มีวันที่ `status text not null default 'forming'` หมายความว่า status ไม่มีทางเป็น null และดังนั้นโค้ดของคุณจึงไม่ต้องมี branch `if status is null` เลย ทุก column ที่คุณปล่อยให้ nullable คือ branch เงื่อนไขที่คุณต้องจ่ายไปตลอดในโค้ดที่อ่านมัน กฎคือ column เป็น NOT NULL โดยปริยาย และคุณทำให้มัน nullable ก็ต่อเมื่อการไม่มีค่าเป็นสถานะของ domain ที่ชอบธรรมและแตกต่างจากค่าว่างเปล่าจริงๆ
ความ unique และ index ความสัมพันธ์ที่ว่า "ผู้ใช้หนึ่งคนมีบันทึกความก้าวหน้าหนึ่งรายการต่อหนึ่งโมดูลพอดี" ไม่ใช่ comment แต่คือ `unique (user_id, module_code)` constraint unique นั้นยังให้ index ที่คุณต้องใช้สำหรับ upsert (`insert ... on conflict (user_id, module_code) do update`) ซึ่งเป็นวิธีที่ MOONKEY persist ความก้าวหน้าพอดี นอกเหนือจาก key แล้ว ให้สร้าง index บน column ที่คุณ filter จริงๆ ถ้าคุณ query proofs ด้วย `user_id` อยู่ตลอด ก็ `create index on proofs (user_id)` แต่อย่าสร้าง index ตามปฏิกิริยาเคยชิน ทุก index ทำให้ INSERT ช้าลงและกินพื้นที่ จงสร้าง index ตามสิ่งที่คุณวัดได้ว่ามีการ query จริง ไม่ใช่สิ่งที่คุณจินตนาการว่าจะถูก query
นำ schema ไปใช้เป็น migration ที่มี version ควบคุม ไม่ใช่ทำมือใน SQL editor ใน Supabase นั่นคือ `apply_migration` (ไฟล์ .sql ที่มีชื่อและ timestamp) ไม่ใช่ `execute_sql` ลอยๆ ความต่างคือการตรวจสอบย้อนหลัง อีกหกเดือนข้างหน้าคุณจะอยากรู้ว่าทำไม proofs ถึงมี column นั้น และคำตอบอยู่ในประวัติของ migration ไม่ใช่ในความทรงจำของคุณ หลังจาก apply แล้ว ให้รัน `generate_typescript_types` เพื่อให้ type ของ TypeScript ฝั่ง client สืบทอดมาจาก schema จริง ฐานข้อมูลจึงเป็นแหล่งความจริงหนึ่งเดียว และ front จะโกหกเรื่องรูปร่างของข้อมูลไม่ได้
ตัวอย่างที่เป็นรูปธรรมและครบถ้วนสำหรับ proofs ซึ่งเป็น table ที่ operator อัปโหลด proof ว่าทำ deliverable เสร็จแล้ว: key ของตัวเองเป็น uuid, user_id แบบ NOT NULL พร้อม FK และ cascade, module_code พร้อม CHECK กับรายการ code ที่ถูกต้อง, payload แบบ jsonb, created_at แบบ timestamptz NOT NULL default now() และ index ตาม user_id การออกแบบนี้ทำให้ (ก) ทุกแถวเป็นของผู้ใช้ที่มีอยู่จริง (ข) ไม่มีแถวกำพร้า (ค) ไม่มี module_code ที่ถูกกุขึ้นเล็ดลอดเข้ามา และ (ง) query "proof ของฉัน" ทำงานเร็ว บนรากฐานนี้ และเฉพาะบนรากฐานนี้เท่านั้น การวาง RLS ในโมดูลถัดไปจึงมีความหมาย
แบบฝึกหัด
ในโปรเจกต์ Supabase moonkey-lab (หรือ branch สำหรับ development) ให้เขียน migration SQL ที่สร้าง table `proofs` ขึ้นใหม่ตั้งแต่ต้น: id uuid PK ด้วย gen_random_uuid(), user_id uuid NOT NULL references auth.users(id) on delete cascade, module_code text NOT NULL พร้อม CHECK กับ code จริงอย่างน้อยสามตัว ('DS-01','DS-02','DS-03'), payload jsonb NOT NULL default '{}', created_at timestamptz NOT NULL default now() และ index บน user_id นำไปใช้ด้วย apply_migration จากนั้นลองตั้งใจ INSERT ที่มี module_code='XX-99' และอีกรายการที่มี user_id เป็น uuid ที่ไม่มีอยู่ใน auth.users แล้วตรวจสอบว่า Postgres ปฏิเสธทั้งสองรายการ สุดท้ายรัน generate_typescript_types และยืนยันว่า type Proof ที่สร้างขึ้นสะท้อน column และ nullability ของคุณอย่างถูกต้อง
สิ่งที่ส่งมอบ
ไฟล์ migration `supabase/migrations/<timestamp>_create_proofs.sql` ที่นำไปใช้ใน branch แล้ว พร้อมภาพหน้าจอ error ของ Postgres เมื่อ INSERT ที่ไม่ถูกต้อง (การละเมิด CHECK และ FK) และ type TypeScript `Proof` ที่สร้างจาก schema
ข้อคิดสำคัญ
โมเดลข้อมูลที่ถูกต้องคือโมเดลที่เปลี่ยน bug ของแอปพลิเคชันให้กลายเป็น error ของฐานข้อมูล ทุก CHECK ทุก NOT NULL และทุก FK คือ bug ทั้งคลาสที่โค้ดแอปพลิเคชันของคุณไม่ต้องคอยป้องกันอีกต่อไป เพราะ Postgres ปฏิเสธมันก่อนที่มันจะเกิดขึ้น
ข้อผิดพลาดที่ควรเลี่ยง
- ×ใช้ `timestamp` แทน `timestamptz` แล้วมาค้นพบความคลาดเคลื่อนของเขตเวลาตอนที่ผู้ใช้ในประเทศไทยเห็นวันที่ผิดเท่านั้น บน production ตอนนั้นก็สายเกินไปแล้ว
- ×ปล่อย column ให้ nullable "เผื่อไว้ก่อน" ทุก nullable ที่ไม่จำเป็นคือ branch `if x is null` ที่คุณต้องแบกไว้ในโค้ดทุกที่ที่อ่าน table นั้น
- ×สร้าง table profiles ด้วย id ของตัวเองแทนที่จะ `references auth.users(id)` ทำให้ห่วงโซ่ระหว่าง auth.uid() กับ identity ขาด และทิ้ง RLS ที่จะตามมาให้ไม่มี field ไว้เปรียบเทียบ
- ×นำ schema ไปใช้ด้วย execute_sql แบบทำมือแทน apply_migration คุณสูญเสียประวัติที่มี version ควบคุม และจะไม่มีใครสร้างเหตุผลขึ้นใหม่ได้ว่าทำไม schema ถึงเป็นเช่นนี้
- ×หว่าน index ตามปฏิกิริยาเคยชินบน column ที่คุณไม่เคย filter คุณลงโทษทุก INSERT และกินพื้นที่โดยไม่เร่งความเร็วให้ query จริงสักรายการ
DS-02 RLS: อำนาจอยู่ที่เซิร์ฟเวอร์
บทเรียน เขียนและตรวจสอบ policy Row Level Security ที่ทำให้ผู้ใช้อ่านและเขียนได้เฉพาะแถวของตัวเอง โดยเข้าใจว่า gate ฝั่ง client คือ UX และ RLS คือความปลอดภัยจริงเพียงหนึ่งเดียวในเว็บไซต์แบบ static
RLS: อำนาจอยู่ที่เซิร์ฟเวอร์
บทเรียนเขียนและตรวจสอบ policy Row Level Security ที่ทำให้ผู้ใช้อ่านและเขียนได้เฉพาะแถวของตัวเอง โดยเข้าใจว่า gate ฝั่ง client คือ UX และ RLS คือความปลอดภัยจริงเพียงหนึ่งเดียวในเว็บไซต์แบบ static
MOONKEY LAB และ Espejo เป็นเว็บไซต์แบบ static ที่ไม่มี handler ฝั่ง server ไม่มี backend ให้ใส่ `if (user.id === row.user_id)` client ถือ anon key ซึ่งเปิดเผยต่อสาธารณะโดยการออกแบบ ถ้า RLS ผิด ใครก็ตามที่เปิด DevTools ก็อ่าน table profiles ได้ทั้งหมด RLS ไม่ใช่ชั้นป้องกันเพิ่มอีกชั้น แต่เป็นชั้นป้องกันเพียงชั้นเดียว
บทเรียน
ทำความเข้าใจ threat model ให้ซึมซับก่อนแตะ policy แม้แต่ตัวเดียว ในเว็บ SSG ที่ใช้ Supabase ผู้โจมตีไม่ได้ใช้หน้าจอของคุณ เขาเปิด console ของ browser หยิบ anon key (ซึ่งอยู่ใน bundle เพราะมันต้องอยู่ที่นั่น) สร้าง Supabase client ของตัวเองและเรียก `supabase.from('profiles').select('*')` ไฟล์ admin.astro ของคุณที่ "ซ่อน" panel ไว้นั้นไม่มีอยู่จริงสำหรับเขา ปุ่มที่ถูก disable ก็ไม่มีอยู่จริงสำหรับเขา สิ่งเดียวที่ขวางอยู่ระหว่าง SELECT ของเขากับทุกแถวของผู้ใช้ทุกคนคือ Row Level Security ของ Postgres นั่นคือเหตุผลของประโยคประจำโมดูล: gate ฝั่ง client คือ UX (ปรับปรุงประสบการณ์ของผู้ใช้ที่ชอบธรรม) RLS คือความปลอดภัย (หยุดผู้ที่ไม่ชอบธรรม)
RLS เปิดใช้งานเป็นราย table และโดยปริยายคือ deny-all `alter table profiles enable row level security` และในวินาทีนั้นเอง เมื่อไม่มี policy ใดเลย ไม่มีใคร (ยกเว้น service role และเจ้าของ table) อ่านหรือเขียนอะไรได้ นี่ถูกต้องแล้ว คุณเริ่มจาก permission เป็นศูนย์และเปิดเฉพาะสิ่งที่คุณต้องการ ความผิดพลาดร้ายแรงคือเปิด RLS แล้วลืม operation บางอย่าง ถ้าคุณวาง policy ของ SELECT แต่ไม่มีของ INSERT เลย INSERT จะล้มเหลวอย่างเงียบเชียบ แย่กว่านั้นคือ ถ้าคุณไม่เปิด RLS บน table ใหม่ มันจะเปิดโล่งสนิทต่อ anon key นี่คือเหตุผลที่ DS-06 แนะนำ advisors เพราะมันตรวจจับ table ที่คุณลืมเปิด RLS ได้อย่างแม่นยำ
policy หนึ่งตัวมีสอง clause ที่ทำให้ทุกคนสับสน: USING และ WITH CHECK USING กรองว่า operation (SELECT, UPDATE, DELETE) มองเห็นหรือแตะแถวที่มีอยู่แล้วแถวไหนได้บ้าง WITH CHECK ตรวจสอบว่าแถวใหม่หรือแถวที่ถูกแก้ไขแถวไหนที่อนุญาตให้เขียนได้ (INSERT, UPDATE) สำหรับ SELECT มีแค่ USING สำหรับ INSERT มีแค่ WITH CHECK สำหรับ UPDATE มีทั้งสอง: USING บอกว่าคุณ update แถวไหนได้ WITH CHECK บอกว่ามันกลายเป็นอะไรได้ ความผิดพลาดคลาสสิกคือวาง USING ใน INSERT แล้วมันไม่ทำอะไรเลย หรือวางแค่ USING ใน UPDATE แล้วปล่อยให้ผู้ใช้ย้ายแถวของตัวไปยัง user_id อื่น pattern self-or-admin ของ MOONKEY: `using (user_id = auth.uid() or is_admin())`
pattern มาตรฐานแบบ self สำหรับ proofs policy การ insert จริงของ MOONKEY คือ `proofs_insert_self`: `create policy proofs_insert_self on proofs for insert to authenticated with check (user_id = auth.uid())` อ่านออกเสียงดูสิ: ผู้ใช้ที่ authenticated แล้ว insert proof ได้ก็ต่อเมื่อ user_id ของแถวที่เขา insert เท่ากับ identity ของเขาเอง เขาสร้าง proof ในนามคนอื่นไม่ได้ เพราะ WITH CHECK จะปฏิเสธแถวใดก็ตามที่ user_id ≠ auth.uid() สังเกต `to authenticated`: policy ไม่ได้ใช้กับ role anon ด้วยซ้ำ ดังนั้นผู้เยี่ยมชมที่ไม่มี session จึง insert อะไรไม่ได้เลย สำหรับ SELECT ใช้ pattern self-or-admin: `using (id = auth.uid() or is_admin())` บน profiles คุณเห็นแถวของตัวเอง หรือทุกแถวถ้าคุณเป็น admin
auth.uid() คือหัวใจของระบบและควรรู้ว่ามันคืออะไร: ฟังก์ชัน SECURITY DEFINER ของ Supabase ที่ดึง `sub` จาก JWT ที่ client ส่งมาในทุก request client ปลอมแปลงมันไม่ได้เพราะ JWT ถูกเซ็นโดย Supabase ด้วย secret ที่ client ไม่มี การไปแก้ไขมันทำให้ลายเซ็นเสีย และ Postgres ปฏิเสธ session นั้น นี่คือเหตุผลที่ `user_id = auth.uid()` เชื่อถือได้ในแบบที่ `user_id = <ค่าที่ client ส่งมา>` ไม่มีวันเป็น อำนาจอยู่ในลายเซ็นของ token ไม่ใช่ในความสุจริตใจของ front
การตรวจสอบด้วยการสวมรอย (impersonation) นี่คือสิ่งที่แยก operator ที่จริงจังออกจากคนที่ "เชื่อ" ว่า RLS ของตัวทำงาน แค่อ่าน policy แล้วพยักหน้านั้นไม่พอ ใน SQL editor ของ Supabase คุณจำลองการเป็นผู้ใช้รายหนึ่งได้ด้วยการกำหนด role และ claim: `set local role authenticated; set local request.jwt.claims to '{"sub":"<uuid-ของผู้ใช้-A>"}';` แล้วจึง `select * from profiles` ถ้าคุณเห็นแถวของผู้ใช้คนอื่น RLS ของคุณพังแล้ว โมเดลความปลอดภัยของ MOONKEY ระบุชัดว่า "ตรวจสอบด้วยการสวมรอย": ผู้ที่ไม่ใช่ admin อ่านแถวของคนอื่นไม่ได้ และนั่นได้รับการพิสูจน์แล้ว ไม่ใช่สันนิษฐานเอา จงทำเช่นเดียวกัน: สวมรอยเป็นผู้ใช้ A ลองอ่านแถวของผู้ใช้ B และเรียกร้องให้ผลลัพธ์เป็นศูนย์แถว
กับดักสองอย่างสุดท้าย อย่างแรก: role `service_role` (service key ที่ไม่มีวันอยู่ใน client) ข้าม RLS ทั้งหมด นั่นคือเหตุผลที่มันไม่มีวันอยู่ในเว็บ SSG และใช้เฉพาะใน edge function ที่เชื่อถือได้เท่านั้น อย่างที่สอง: policy เป็นแบบ permissive โดยปริยายและรวมกันด้วย OR ไม่ใช่ AND ถ้าคุณมี policy ของ SELECT สอง policy บน table เดียวกัน แถวที่มองเห็นได้จาก policy ใดก็ตามในสองตัวก็จะมองเห็นได้ สิ่งนี้ทำให้ประหลาดใจ: การเพิ่ม policy ไม่มีวันจำกัด มีแต่ขยาย ถ้าจะจำกัด คุณต้องใช้ policy แบบ RESTRICTIVE ที่ระบุชัด หรือซึ่งง่ายกว่าและเป็นวิธีปกติ คือ policy เดียวที่เขียนดีๆ ต่อหนึ่ง operation
แบบฝึกหัด
ใน branch ของ moonkey-lab บน table proofs ที่สร้างไว้แล้ว: เปิด RLS และเขียนสาม policy คือ `proofs_select_self` (SELECT, using user_id=auth.uid() or is_admin()), `proofs_insert_self` (INSERT, with check user_id=auth.uid()) และไม่มี policy UPDATE/DELETE เลย (deny โดยปริยาย) สร้างผู้ใช้ทดสอบสองคน A และ B พร้อม proof คนละรายการ ใน SQL editor สวมรอยเป็น A (set local role authenticated + jwt claims sub=uuid_A) และตรวจสอบว่า: (1) SELECT คืนเฉพาะ proof ของ A; (2) INSERT ที่มี user_id=uuid_B ถูก WITH CHECK ปฏิเสธ; (3) DELETE proof ของ A ถูกปฏิเสธเพราะไม่มี policy บันทึกผลลัพธ์ทั้งสามรายการ
สิ่งที่ส่งมอบ
migration ที่มีสาม policy บน proofs พร้อม log ของ session การสวมรอยที่แสดงพฤติกรรมทั้งสาม: A ไม่เห็น B (SELECT), A เขียนในนาม B ไม่ได้ (INSERT พร้อม WITH CHECK) และไม่มีใครลบได้ (ไม่มี policy DELETE)
ข้อคิดสำคัญ
RLS ที่คุณไม่ได้ตั้งใจทำให้พังด้วยการสวมรอยเป็นผู้ใช้คนอื่น คือ RLS ที่คุณไม่รู้ว่ามันทำงานหรือไม่ ความเชื่อมั่นด้านความปลอดภัยไม่ได้มาจากการอ่าน policy แต่มาจากการที่คุณพยายามใช้ในทางที่ผิดและได้เห็น Postgres ปฏิเสธมัน
ข้อผิดพลาดที่ควรเลี่ยง
- ×สับสนระหว่าง gate ของ admin.astro กับความปลอดภัย: การซ่อน panel บน front ไม่ได้ขัดขวางผู้โจมตีไม่ให้เรียก table โดยตรงด้วย anon key จาก console
- ×วาง USING ในที่ที่ควรเป็น WITH CHECK (หรือกลับกัน): INSERT ที่มีแค่ USING ไม่ตรวจสอบอะไรเลยและปล่อยแถวที่มี user_id ของคนอื่นเข้ามา
- ×เปิด RLS แล้วลืม policy ของ operation บางอย่าง: INSERT เริ่มล้มเหลวอย่างเงียบเชียบ หรือแย่กว่านั้น table ที่ไม่มี RLS เปิดโล่งสนิทต่อ anon key
- ×สันนิษฐานว่า RLS ทำงานเพราะได้อ่านมัน แทนที่จะตรวจสอบด้วยการสวมรอยด้วย set role + jwt claims และเรียกร้องให้ได้ศูนย์แถวของคนอื่น
- ×เชื่อว่าการเพิ่ม policy แบบ permissive ตัวที่สองจะจำกัดการเข้าถึง: policy รวมกันด้วย OR ดังนั้นทุก policy ใหม่มีแต่ขยายสิ่งที่มองเห็นได้ ไม่มีวันลดลง
DS-03 Auth แบบ magic-link และเซสชัน
บทเรียน ทำ authentication ด้วย magic-link กับ Supabase และจัดการ session ฝั่ง client อย่างซื่อตรง: รู้ว่า session รับประกันอะไร ไม่รับประกันอะไร และเหตุใดสิ่งนี้จึงไม่ทำให้ RLS ของคุณอ่อนแอลง
Auth แบบ magic-link และเซสชัน
บทเรียนทำ authentication ด้วย magic-link กับ Supabase และจัดการ session ฝั่ง client อย่างซื่อตรง: รู้ว่า session รับประกันอะไร ไม่รับประกันอะไร และเหตุใดสิ่งนี้จึงไม่ทำให้ RLS ของคุณอ่อนแอลง
MOONKEY LAB และ Espejo ใช้การ login แบบไม่มีรหัสผ่าน: ผู้ใช้กรอก email รับลิงก์ คลิก แล้วก็ authenticate สำเร็จ นี่คือ UX ที่ดีที่สุดและขจัดช่องโหว่ทั้งคลาส (ไม่เก็บรหัสผ่าน ไม่มีการรั่วของ hash) แต่ session อยู่ใน browser และ operator ที่ไม่เข้าใจว่า token ถูกเก็บที่ไหนและอย่างไรจะสับสนระหว่างความสะดวกกับความปลอดภัย
บทเรียน
magic-link ทำงานอย่างไร ทีละขั้น client เรียก `supabase.auth.signInWithOtp({ email })` Supabase สร้าง token แบบใช้ครั้งเดียว ผูกมันกับ email แล้วส่งอีเมลพร้อมลิงก์ที่ชี้ไปยังแอปของคุณโดยมี token นั้นอยู่ใน fragment ของ URL ผู้ใช้คลิก แอปของคุณเมื่อโหลดจะตรวจจับ token นั้น แลกมันกับ Supabase เป็นคู่ access_token / refresh_token และจากนั้น client ก็ authenticate แล้ว access_token เป็น JWT ที่เซ็นแล้วและมีอายุสั้น (โดยปริยายหนึ่งชั่วโมง) ส่วน refresh_token มีอายุยาวและใช้เพื่อขอ access token ใหม่โดยไม่ต้องให้ผู้ใช้ login อีก ทั้งหมดนี้ SDK จัดการให้ งานของคุณคือเข้าใจ flow ไม่ใช่เขียนมันขึ้นใหม่
session อยู่ที่ไหน โดยปริยาย SDK ของ Supabase ใน browser จะ persist session ไว้ใน localStorage สิ่งนี้มีผลด้านความปลอดภัยที่คุณต้องพูดออกมาดังๆ: token ใน localStorage อ่านได้โดย JavaScript ใดก็ตามที่รันบนหน้าของคุณ นั่นหมายความว่าพื้นผิวการโจมตีอันดับหนึ่งของคุณคือ XSS ถ้าผู้โจมตีฉีด JS เข้ามาในเว็บของคุณได้ (script ของ third-party ที่ถูกเจาะ, innerHTML ที่ใส่ input ที่ไม่ได้ sanitize) เขาก็อ่าน token และขโมย session ได้ การป้องกันไม่ใช่การซ่อน token แต่คือการไม่มี XSS: อย่าใส่ HTML ของผู้ใช้ที่ไม่ได้ sanitize, ตรวจสอบทุก dependency ของ front และปฏิบัติต่อทุก `<script>` ของ third-party เสมือนเป็นโค้ดที่จะมองเห็น token ของคุณ
ความแตกต่างอันซื่อตรงที่ตั้งชื่อให้โมดูลนี้: session คือ identity ไม่ใช่ authorization การที่ client มี access_token ที่ถูกต้องเป็นการพิสูจน์ว่า เป็นใคร (auth.uid() จะคืน uuid ของเขา) แต่ไม่ได้ให้สิทธิ์ทำอะไรในตัวมันเอง RLS เป็นผู้ตัดสินสิทธิ์ในทุก query สิ่งนี้ปลดปล่อย: คุณไม่ต้องปกป้องข้อมูลของคุณที่ front แม้ผู้โจมตีจะขโมย session ได้ เขาก็ทำได้แค่สิ่งที่ RLS อนุญาตให้ ผู้ใช้คนนั้น ทำ คือเห็นแถวของตัวเอง ไม่ใช่ของคนอื่น session ที่ถูกขโมยจากผู้ใช้ธรรมดาไม่ได้ให้สิทธิ์ admin เพราะ is_admin() ตรวจสอบใหม่กับแถวใน profiles ไม่ใช่กับ claim ที่ client แก้ไขได้
การจัดการสถานะ session ในแอป SDK เปิด `supabase.auth.getSession()` (อ่าน session ปัจจุบัน อาจมาจาก localStorage) และ `supabase.auth.onAuthStateChange((event, session) => ...)` (แจ้งคุณเรื่อง SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED) ในเว็บ SSG อย่าง MOONKEY คุณไม่มีการ render สถานะ auth ฝั่ง server ดังนั้นหน้าจึงโหลดในสถานะ "ไม่ทราบ" ก่อน แล้วจึงค่อยตัดสินฝั่ง client ว่ามี session หรือไม่ จงออกแบบเผื่อสิ่งนี้: แสดงสถานะ loading ที่เป็นกลาง อย่ากะพริบไปมาระหว่าง "ผู้เยี่ยมชม" กับ "ล็อกอินแล้ว" gate เชิงภาพ (แสดง /cuenta เฉพาะเมื่อมี session) เป็น UX ที่ชอบธรรม จำ DS-02 ไว้: นั่นคือ UX ความปลอดภัยยังคงอยู่ที่ RLS
trigger ที่ปิดวงจร เมื่อผู้ใช้ลงทะเบียนครั้งแรกผ่าน magic-link Supabase สร้างแถวใน auth.users แต่แอปของคุณต้องการแถวที่สอดคล้องกันใน profiles สิ่งนี้ client ไม่ได้ทำ (ต้องไม่ให้เขาเลือก role หรือ founder_badge ของตัวเองได้) แต่ทำโดย trigger แบบ SECURITY DEFINER ในฐานข้อมูล คือ `handle_new_user` ที่ทำงาน `after insert on auth.users` และสร้างแถว profiles ด้วยค่าปริยายที่ปลอดภัย (role='monkey' ไม่มีวันเป็น admin) ดังนั้นการสร้าง profile จึงเป็นอำนาจของ server: ผู้ใช้เกิดมาเป็น admin ไม่ได้ เพราะ trigger ไม่ใช่ client เป็นผู้ตัดสินค่าเริ่มต้น คุณจะพัฒนา pattern นี้อย่างลึกใน DS-04
logout และการหมดอายุ ทำอย่างซื่อตรง `supabase.auth.signOut()` ลบ token ออกจาก localStorage และเพิกถอน refresh_token บน server สำคัญ: ถ้าคุณแค่ลบ localStorage ด้วยมือโดยไม่เรียก signOut refresh_token ยังคงใช้ได้บน server จงทำผ่าน SDK เสมอ เรื่องการหมดอายุ: อย่าสัญญาว่า "session อยู่ตลอดไป" access_token หมดอายุในหนึ่งชั่วโมง และ SDK refresh มันด้วย refresh_token อย่างโปร่งใส ถ้า refresh_token ถูกเพิกถอนหรือหมดอายุ ผู้ใช้จะกลับไป login จงสื่อสารสิ่งนี้กับผู้ใช้อย่างซื่อตรงแทนการเสแสร้งว่ามันคงอยู่ชั่วนิรันดร์
ตั้งค่า redirect URL ใน panel ของ Supabase (Auth > URL Configuration) magic-link redirect ไปยัง URL ที่ต้องอยู่ใน allowlist มิฉะนั้น Supabase จะปฏิเสธการแลก token สิ่งนี้ป้องกันไม่ให้ผู้โจมตีทำให้ลิงก์ redirect ไปยัง domain ที่เขาควบคุม ใน MOONKEY redirect URL ครอบคลุม domain ของ production (moonkeylab.pages.dev) และ localhost สำหรับ development เท่านั้น ไม่มีอย่างอื่น allowlist ของ redirect ที่หละหลวมคือ vector ของ phishing ที่เกิดขึ้นได้จริง
แบบฝึกหัด
ใน MOONKEY (หรือ clone บนเครื่องที่ชี้ไปยัง branch ของ Supabase) ให้ทำ flow ทั้งหมด: หน้า login ที่เรียก signInWithOtp ด้วย email ของผู้ใช้และแสดง "ตรวจสอบอีเมลของคุณ"; การจัดการการแลก token เมื่อกลับมาจากลิงก์; และหน้า /cuenta ที่ใช้ getSession + onAuthStateChange เพื่อแสดง email ของผู้ใช้ที่ล็อกอินแล้วหรือ redirect ไป login ถ้าไม่มี session ตรวจสอบสามอย่าง: (1) หลังคลิก magic-link มีแถวใน profiles ที่ trigger handle_new_user สร้างขึ้นพร้อม role='monkey'; (2) signOut ลบ session และเพิกถอน refresh token; (3) ตั้งค่า redirect URL ใน panel และยืนยันว่าการ redirect ไปยัง domain ที่ไม่อยู่ในรายการถูกปฏิเสธ
สิ่งที่ส่งมอบ
flow การ login ด้วย magic-link ที่ทำงานได้กับ branch ของ Supabase พร้อมภาพหน้าจอของ: แถว profiles ที่ trigger สร้างขึ้นอัตโนมัติ (role='monkey'), สถานะ session ที่อ่านได้ใน /cuenta และหน้า Auth > URL Configuration ที่แสดง allowlist ของ redirect URL
ข้อคิดสำคัญ
session พิสูจน์ว่าคุณเป็นใคร ไม่ใช่ว่าคุณทำอะไรได้ ถ้าความปลอดภัยของคุณพังเมื่อมีคนขโมย session แสดงว่าคุณกำลังพึ่ง client ในการ authorize และ authorization ต้องอยู่ที่ RLS เสมอ ที่ซึ่ง session ที่ถูกขโมยเปิดได้แค่สิ่งที่ ผู้ใช้คนนั้น มองเห็นได้อยู่แล้ว
ข้อผิดพลาดที่ควรเลี่ยง
- ×ปฏิบัติต่อ token ใน localStorage เสมือนความลับที่ปลอดภัย: มันอ่านได้โดย JS ใดก็ตามบนหน้า ดังนั้นการป้องกันที่แท้จริงของคุณคือการไม่มี XSS ไม่ใช่การซ่อน token
- ×สับสนระหว่างการมี session กับการมีสิทธิ์: session ให้ identity (auth.uid()) แต่ทุกการเข้าถึงยังคงถูกตัดสินโดย RLS จงอย่า authorize ที่ front
- ×สร้างแถว profiles จาก client แทนที่จะใช้ trigger handle_new_user: คุณจะปล่อยให้ผู้ใช้เลือก role ของตัวเองและเปิดทางให้ยกระดับสิทธิ์ตัวเองเป็น admin
- ×ลบ localStorage ด้วยมือแทนการเรียก signOut: refresh_token ยังมีชีวิตอยู่บน server และ session กลับมาทำงานต่อได้
- ×ปล่อย allowlist ของ redirect URL ให้เปิดกว้างหรือมี wildcard: เปลี่ยน magic-link ให้เป็น vector ของ phishing ที่ redirect ไปยัง domain ของผู้โจมตี
DS-04 SECURITY DEFINER, RPC และ trigger
บทเรียน เขียนฟังก์ชัน SECURITY DEFINER, RPC และ trigger ที่รัน logic ที่มีสิทธิ์พิเศษอย่างควบคุมได้ โดยไม่เปิดรูรั่วของการยกระดับสิทธิ์
SECURITY DEFINER, RPC และ trigger
บทเรียนเขียนฟังก์ชัน SECURITY DEFINER, RPC และ trigger ที่รัน logic ที่มีสิทธิ์พิเศษอย่างควบคุมได้ โดยไม่เปิดรูรั่วของการยกระดับสิทธิ์
มี operation ที่ RLS เพียงลำพังแสดงออกไม่ได้: การตรวจสอบว่าใครเป็น admin (ตัวการตรวจสอบเองต้องอ่าน profiles ซึ่งจะสร้าง recursion), การเลื่อนระดับของ operator พร้อมตรวจสอบกฎใหม่ หรือการป้องกันไม่ให้ผู้ใช้ติด founder_badge ให้ตัวเอง SECURITY DEFINER คือเครื่องมือ และเป็นที่ที่เมื่อใช้ผิด คุณจะเปิดประตูหลังที่ RLS ทั้งหมดของคุณพยายามปิด
บทเรียน
SECURITY DEFINER หมายความว่าอะไร ฟังก์ชันปกติใน Postgres รันด้วยสิทธิ์ของผู้ เรียก (SECURITY INVOKER ซึ่งเป็นค่าปริยาย) ฟังก์ชัน SECURITY DEFINER รันด้วยสิทธิ์ของผู้ สร้าง (โดยทั่วไปคือ role ที่มีสิทธิ์พิเศษ เจ้าของ table) สิ่งนี้ทำให้มันทำสิ่งที่ผู้เรียกทำเองโดยตรงไม่ได้ เช่น อ่าน profiles เพื่อตรวจสอบ role แม้ RLS จะปฏิเสธ SELECT นั้นแก่ผู้ใช้ มันทรงพลังและอันตราย SECURITY DEFINER คือโค้ดชิ้นเล็กๆ ที่รันเหนือ RLS แต่ละตัวคือข้อยกเว้นของโมเดลความปลอดภัยของคุณ ดังนั้นแต่ละตัวจึงต้องถูกตรวจสอบในฐานะนั้น
กรณี is_admin() คุณต้องรู้ว่าผู้ใช้ปัจจุบันเป็น admin หรือไม่เพื่อใช้ใน policy (`using (... or is_admin())`) แต่ถ้า policy ของ SELECT บน profiles ขึ้นกับการอ่าน profiles เพื่อรู้ role คุณจะได้ recursion ไม่รู้จบ คือ เพื่ออ่านแถวของคุณ ต้องรู้ว่าคุณเป็น admin หรือไม่ เพื่อรู้สิ่งนั้น ต้องอ่าน profiles ซึ่งจุดชนวน policy อีกครั้ง ทางออกคือ is_admin() แบบ SECURITY DEFINER: รันด้วยสิทธิ์ของเจ้าของ อ่าน profiles โดยไม่ผ่าน RLS คืนค่า boolean สิ่งสำคัญคือ มันไม่คืนข้อมูลที่อ่อนไหว คืนแค่ true/false เกี่ยวกับผู้เรียก (`select role = 'admin' from profiles where id = auth.uid()`) SECURITY DEFINER ที่ปลอดภัยเปิดเผยข้อมูลน้อยที่สุด: เป็นการตัดสินใจ ไม่ใช่ dataset
ปิดป้อง search_path นี่คือช่องโหว่คลาสสิกและเป็นสิ่งที่ advisors ระบุไว้อย่างไม่ปรานี SECURITY DEFINER ที่ไม่กำหนด search_path ของตัวเองถูก exploit ได้: ผู้โจมตีสร้าง table หรือฟังก์ชันที่ชื่อเดียวกับที่ฟังก์ชันของคุณใช้ ใน schema ที่อยู่ก่อนใน search_path และฟังก์ชันที่มีสิทธิ์พิเศษของคุณก็รันโค้ดของผู้โจมตีด้วยสิทธิ์ของเจ้าของ การป้องกันเป็นข้อบังคับ: `create function is_admin() ... security definer set search_path = '' as $$ ... $$;` (หรือ `set search_path = pg_catalog, public` พร้อมระบุชื่ออย่างชัดเจน) เมื่อ search_path ว่างเปล่า คุณต้องอ้างทุก table ด้วย schema เต็ม: `public.profiles` ไม่ใช่ `profiles` หากปราศจากสิ่งนี้ ฟังก์ชันความปลอดภัยของคุณ คือ รูรั่ว
RPC: business logic ที่เรียกได้จาก client RPC ใน Supabase คือฟังก์ชัน Postgres ที่เปิดผ่าน `supabase.rpc('ชื่อ', args)` ใน MOONKEY RPC จริงคือ update_operator_rank (เลื่อนระดับพร้อมตรวจสอบใหม่), my_referral_stats (คืนสถิติการแนะนำของผู้ใช้) และ is_admin pattern ทองคำคือ RPC ไม่เชื่อ argument ของ client สำหรับเรื่อง identity update_operator_rank ไม่รับ "จะเลื่อนผู้ใช้คนไหน" เป็น parameter อิสระ แต่ใช้ auth.uid() ภายใน ถ้ามันรับ user_id เป็น argument ผู้โจมตีจะเรียก rpc('update_operator_rank', { user_id: 'ของคนอื่น' }) identity ออกมาจาก auth.uid() ภายในฟังก์ชันเสมอ ไม่ใช่จาก parameter ที่ client ควบคุม
RPC ตรวจสอบใหม่ ไม่ใช่เชื่อฟัง update_operator_rank ไม่ใช่ "ตั้งระดับของฉันเป็น X เพราะฉันขอ" มันตรวจสอบกฎใหม่: ผู้ใช้ทำ proof ที่ต้องการสำหรับระดับนั้นครบหรือยัง comment ในโมเดลความปลอดภัยของ MOONKEY ระบุชัด "ระดับเปลี่ยนได้ผ่าน update_operator_rank เท่านั้น (ตรวจสอบ role ใหม่)" client กระโดดจาก monkey ไป admin ด้วยการขอไม่ได้ RPC ตรวจสอบเงื่อนไขจริงในฐานข้อมูลและจึงค่อยเขียน นี่คือความต่างระหว่าง RPC ที่เป็น business API (ตรวจสอบ invariant) กับ RPC ที่เป็นรูรั่ว (เขียนตามที่บอก) มอบ EXECUTE ให้เฉพาะ `authenticated` ไม่ใช่ anon: `grant execute on function update_operator_rank to authenticated`
trigger: invariant ที่บังคับใช้ไม่ว่าจะเกิดอะไรขึ้น บาง invariant พึ่งให้แอปเคารพมันไม่ได้ "ผู้ที่ไม่ใช่ admin ไม่มีวันเปลี่ยน role หรือ founder_badge ของตัวเอง" คือหนึ่งในนั้น ถ้ามันขึ้นกับแอป UPDATE ตรงๆ ผ่าน anon key ก็จะข้ามมันไป ทางออกของ MOONKEY คือ trigger guard_privileged_profile_columns ที่ทำงาน `before update on profiles` และหากผู้เรียกไม่ใช่ admin และพยายามเปลี่ยน role หรือ founder_badge มันจะ throw exception ที่ยกเลิก transaction เมื่อรวมกับ handle_new_user (after insert on auth.users สร้าง profiles ด้วย role='monkey') ผลคือผู้ใช้ เกิด มาเป็น monkey และเลื่อนระดับตัวเองไม่ได้ ทั้งผ่านแอปและผ่าน UPDATE ดิบด้วย anon key trigger คือตาข่ายใต้ RLS
วินัยในการตรวจสอบ (audit) ทุก SECURITY DEFINER ที่คุณเขียน: กำหนด search_path แล้วหรือยัง? คืนข้อมูลน้อยที่สุดเท่าที่เป็นไปได้หรือไม่? ดึง identity จาก auth.uid() ไม่ใช่จาก parameter หรือไม่? EXECUTE ถูกจำกัดให้ role ที่ถูกต้องหรือไม่? หลังสร้างหรือเปลี่ยนฟังก์ชันเหล่านี้ ให้รัน get_advisors (DS-06) advisor ด้านความปลอดภัยจะระบุ SECURITY DEFINER ที่ไม่มี search_path และฟังก์ชันที่มี permission หละหลวม SECURITY DEFINER คือโค้ดที่มีสิทธิ์พิเศษ จงปฏิบัติต่อมันด้วยความหวาดระแวงที่สมควรแก่โค้ดที่รันเหนือความปลอดภัยของคุณเอง
แบบฝึกหัด
ใน branch ของ moonkey-lab: (1) เขียน is_admin() แบบ SECURITY DEFINER พร้อม `set search_path = ''` ที่อ่าน public.profiles และคืนค่า boolean เกี่ยวกับ auth.uid() (2) เขียน trigger before-update บน profiles ที่ยกเลิกหากผู้ที่ไม่ใช่ admin พยายามแก้ไข role หรือ founder_badge และทำให้มันโดยดึง admin จาก is_admin() (3) ทดสอบการใช้ในทางที่ผิด: ด้วย session ของผู้ใช้ธรรมดา (สวมรอย) ลอง `update profiles set role='admin' where id=auth.uid()` และตรวจสอบว่า trigger throw exception (4) รัน get_advisors(type='security') และยืนยันว่าไม่มี warning ของ search_path mutable บนฟังก์ชันของคุณ
สิ่งที่ส่งมอบ
migration ที่มี is_admin() (SECURITY DEFINER, กำหนด search_path แล้ว) และ trigger guard บน profiles พร้อมหลักฐานของ: UPDATE ยกระดับสิทธิ์ตัวเองที่ถูก trigger ปฏิเสธ และ get_advisors ด้านความปลอดภัยที่สะอาด (ไม่มี warning ของ search_path)
ข้อคิดสำคัญ
SECURITY DEFINER คือส่วนเดียวของระบบของคุณที่รันเหนือ RLS ดังนั้นจึงเป็นที่เดียวที่ความสะเพร่ายกระดับเป็นการรั่วทั้งระบบ กฎขั้นต่ำที่ต่อรองไม่ได้: กำหนด search_path, identity มาจาก auth.uid() ไม่ใช่จาก parameter และจำกัดสิทธิ์ EXECUTE เพราะที่นี่ไม่มีตาข่ายชั้นที่สอง
ข้อผิดพลาดที่ควรเลี่ยง
- ×สร้าง SECURITY DEFINER โดยไม่มี `set search_path`: ปล่อยให้ผู้โจมตียึดชื่อ table/ฟังก์ชันและรันโค้ดของเขาด้วยสิทธิ์ของเจ้าของ advisors ระบุมันไว้ด้วยเหตุผล
- ×ส่ง user_id เป็น argument ของ RPC แทนการใช้ auth.uid() ภายใน: client จะเรียก RPC ด้วย id ของผู้ใช้คนอื่นและกระทำการในนามของเขา
- ×ทำให้ update_operator_rank เชื่อฟังระดับที่ขอมาแทนการตรวจสอบเงื่อนไขใหม่: เปลี่ยนการเลื่อนระดับให้กลายเป็นการยกระดับสิทธิ์ตัวเองในคลิกเดียว
- ×พึ่งแอปในการกันไม่ให้ผู้ใช้เปลี่ยน role ของตัวเอง: UPDATE ตรงๆ ด้วย anon key จะข้ามมันไป invariant ต้องอยู่ใน trigger
- ×มอบ EXECUTE ของ RPC ให้ anon หรือ public แทนที่จะให้เฉพาะ authenticated: คุณเปิดเผย business logic ที่มีสิทธิ์พิเศษต่อ request ที่ไม่มี session
DS-05 การ sync ระหว่างเครื่องและคลาวด์
บทเรียน ออกแบบการ sync ระหว่าง local↔cloud ที่ซึ่งสถานะที่อยู่ใน localStorage ขึ้นไปยัง Postgres โดยไม่สูญเสียข้อมูลหรือสร้างรายการซ้ำ และแก้ไขความขัดแย้งอย่างกำหนดผลได้แน่นอน
การ sync ระหว่างเครื่องและคลาวด์
บทเรียนออกแบบการ sync ระหว่าง local↔cloud ที่ซึ่งสถานะที่อยู่ใน localStorage ขึ้นไปยัง Postgres โดยไม่สูญเสียข้อมูลหรือสร้างรายการซ้ำ และแก้ไขความขัดแย้งอย่างกำหนดผลได้แน่นอน
Espejo เริ่มต้นด้วยสถานะของมันใน localStorage (seam Store ของมันออกแบบให้เปลี่ยนจาก localStorage ไป Supabase ได้โดยไม่ต้องเขียนแอปใหม่) และ MOONKEY persist ความก้าวหน้าของ operator ที่แรกเริ่มอยู่ใน browser แล้วต้องขึ้น cloud เมื่อผู้ใช้ login ถ้าการ sync ทำอย่างไร้เดียงสา ผู้ใช้ที่ก้าวหน้าตอน offline แล้ว login ทีหลังจะสูญเสียความก้าวหน้า หรือมันซ้ำ หรือเขียนทับสิ่งที่มีอยู่บนอีกอุปกรณ์
บทเรียน
ปัญหาจริงไม่ใช่ "คัดลอกข้อมูล" แต่คือการกระทบยอดสองแหล่งความจริงที่วิวัฒน์แยกกัน คือ localStorage ของ browser นี้ กับแถวใน Postgres (ที่อาจเปลี่ยนไปจากอีกอุปกรณ์) ก่อนเขียนโค้ด ให้ตัดสินนโยบายความขัดแย้งอย่างชัดเจน เพราะ "แล้วแต่จะเกิดอะไรขึ้น" คือสูตรของการสูญเสียข้อมูล ทางเลือกที่ตรงไปตรงมาคือ last-write-wins (timestamp ล่าสุดชนะ ง่ายแต่อาจเสียการแก้ไขที่เกิดพร้อมกัน), merge ราย field (รวมทีละ field ตามกฎ) หรือ append-only (ไม่เขียนทับ เพิ่มอย่างเดียว แล้วสืบทอดสถานะ) สำหรับความก้าวหน้าในการเรียนรู้ ซึ่งเป็น monotonic คือก้าวหน้าอย่างเดียว นโยบายที่ดีที่สุดมักเป็น "ค่ามากชนะ": ถ้า local บอกโมดูล 3 เสร็จและ cloud บอกโมดูล 5 ผลคือ 5 ไม่มีวันถอยหลัง
localStorage เป็นชั้นหนึ่ง ไม่ใช่ความจริง pattern ของ seam Store ใน Espejo คือ abstraction ที่ถูกต้อง: แอปของคุณไม่เรียก localStorage หรือ Supabase โดยตรง แต่เรียก Store ที่มี interface (get, set, list) มี implementation อันหนึ่งชื่อ LocalStore (localStorage) และอีกอันชื่อ SupabaseStore (Postgres) แอปไม่รู้ว่าใช้ตัวไหน สิ่งนี้เปลี่ยน "การย้ายขึ้น cloud" จากการเขียนใหม่ทั้งหมดให้เป็นการเปลี่ยน implementation หลัง interface เดียวกัน การ sync จึงเป็น operation ระหว่าง Store สองตัว: อ่านทั้งหมดจาก LocalStore, กระทบยอดกับ SupabaseStore, เขียนผลลัพธ์ไปทั้งสองที่ จงสร้าง seam ก่อน ที่จะต้องการ cloud เพราะมันถูกในตอนต้นและแพงมหาศาลในการมาแก้ย้อนหลัง
idempotency คือคุณสมบัติที่ช่วยชีวิตคุณ การ sync จะถูกขัดจังหวะ: ผู้ใช้ปิดแท็บกลางคัน เครือข่ายล่ม SDK retry ถ้า sync ของคุณไม่ idempotent การรันครั้งที่สองจะสร้างรายการซ้ำ เครื่องมือคือ upsert ด้วย natural key: `insert into progress (user_id, module_code, completed_at) values (...) on conflict (user_id, module_code) do update set completed_at = greatest(progress.completed_at, excluded.completed_at)` unique (user_id, module_code) จาก DS-01 คือสิ่งที่ทำให้ upsert เป็นไปได้พอดี รัน sync สองครั้งติดกัน: ถ้าสถานะสุดท้ายเหมือนกัน แสดงว่า idempotent ถ้าครั้งที่สองทำให้แถวซ้ำหรือเปลี่ยนอะไร คุณมี bug ที่บน production แสดงตัวเป็นข้อมูลเสียหายตอนเที่ยงคืน
ช่วงเวลาวิกฤต: การ login ครั้งแรกหลังจากทำงานแบบผู้เยี่ยมชม ผู้ใช้ก้าวหน้าใน localStorage โดยไม่มี session แล้วทำ magic-link (DS-03) และได้ auth.uid() ตอนนี้ต้องรับสถานะนิรนามมาไว้ใต้ identity ของเขา flow ที่ปลอดภัย: เมื่อจุดชนวน onAuthStateChange ด้วย SIGNED_IN ให้อ่านความก้าวหน้าจาก LocalStore, ขึ้นมันด้วย upsert ผูก user_id = auth.uid() และเมื่อนั้นค่อยทำเครื่องหมาย local ว่า sync แล้ว จง อย่า ลบ local จนกว่าจะยืนยันว่า cloud ได้รับมัน (การยืนยันคือ response ของ upsert ที่ไม่มี error) ถ้าคุณลบก่อนแล้วการอัปโหลดล้มเหลว คุณก็เสียข้อมูล ลำดับคือ: ขึ้น, ยืนยัน, ทำเครื่องหมาย sync แล้ว, จะเคลียร์ก็ได้
RLS ยังคงเป็นผู้ตัดสินระหว่างการ sync เมื่อคุณขึ้นความก้าวหน้าด้วย session ของผู้ใช้ upsert ไปพร้อม JWT ของเขา ดังนั้น policy `with check (user_id = auth.uid())` จึงมีผล: คุณขึ้นความก้าวหน้าในนามคนอื่นไม่ได้ แม้ localStorage จะบอกเป็นอย่างอื่น สิ่งนี้ดี การ sync ไม่ใช่ประตูหลังสู่ความปลอดภัย มันหมายความว่าคุณต้องผูก user_id กับ auth.uid() ณ ตอนที่ขึ้น ไม่ใช่ใช้ user_id ที่ลากมาจากสถานะนิรนาม (ที่ไม่มี identity จริง) การ sync เคารพโมเดล: server ยังคงเป็นอำนาจว่าแต่ละแถวเป็นของใคร
ความขัดแย้งระหว่างอุปกรณ์ กรณีที่คนมักลืม ผู้ใช้ก้าวหน้าบนมือถือ (ขึ้น cloud) แล้วเปิดแล็ปท็อปที่มีสถานะ local เก่า ถ้าไม่ระวัง แล็ปท็อปจะเขียนทับ cloud ด้วยข้อมูลที่ล้าหลัง การป้องกัน: การกระทบยอด ไม่ใช่ "local ทับ cloud" แต่คือ "กระทบยอดทั้งสองตามนโยบาย" สำหรับความก้าวหน้าแบบ monotonic คุณดึง cloud มา ทำ merge "ค่ามากชนะ" กับ local และเขียนผลลัพธ์ไปทั้งสองที่ แล็ปท็อปก็จะเรียนรู้สิ่งที่มือถือทำไว้แทนการลบมันทิ้ง สำหรับข้อมูลที่ไม่ monotonic คุณต้องการ timestamp ราย field (updated_at) และ last-write-wins ราย field ซึ่งกำหนดให้บันทึก timestamp เหล่านั้นตั้งแต่ต้น อีกหนึ่งเหตุผลของ timestamptz ใน DS-01
สถานะของการ sync มองเห็นได้และซื่อตรง จงสร้างโมเดลอย่างชัดเจน: synced (local == cloud), pending (มีการเปลี่ยนแปลง local ที่ยังไม่ขึ้น), syncing (กำลังดำเนินการ), error (ล้มเหลว ลองใหม่) อย่าโกหกผู้ใช้ด้วยเครื่องหมายถูกสีเขียวหากการอัปโหลดล้มเหลว ตัวบ่งชี้ที่ซื่อตรงว่า "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก" ป้องกันไม่ให้ผู้ใช้ปิดแท็บโดยเชื่อว่าปลอดภัยแล้ว การ sync เงียบๆ ที่ล้มเหลวเงียบๆ แย่กว่าการไม่มี sync เลย: ผู้ใช้ไว้ใจและสูญเสียข้อมูลโดยไม่รู้ตัว
แบบฝึกหัด
บน table progress (user_id, module_code, completed_at พร้อม unique(user_id, module_code)): ทำ seam Store ที่มีสอง backend คือ LocalStore (localStorage) และ SupabaseStore เขียนฟังก์ชัน sync() ที่: อ่านความก้าวหน้า local, กระทบยอดกับของ cloud โดยใช้ 'completed_at มากที่สุดชนะ' ผ่าน upsert ด้วย `on conflict do update set completed_at = greatest(...)` ผูก user_id = auth.uid() ทดสอบสามสถานการณ์: (1) idempotency รัน sync() สองครั้งและตรวจสอบว่าสถานะสุดท้ายเหมือนกัน ไม่มีรายการซ้ำ; (2) การ login ครั้งแรก ก้าวหน้าแบบผู้เยี่ยมชม login แล้วยืนยันว่าความก้าวหน้านิรนามปรากฏใต้ user_id ของคุณใน Postgres; (3) สองอุปกรณ์ จำลอง cloud ด้วยโมดูล 5 และ local ด้วยโมดูล 3 รัน sync และตรวจสอบว่าผลลัพธ์เป็น 5 ทั้งสองฝั่ง (ไม่ถอยหลัง)
สิ่งที่ส่งมอบ
module Store ที่มี interface ร่วมและสอง implementation (LocalStore/SupabaseStore) พร้อมฟังก์ชัน sync() ที่ idempotent บนพื้นฐาน upsert พร้อม log ของสามสถานการณ์: การรันสองครั้งโดยไม่มีรายการซ้ำ, การรับสถานะนิรนามเมื่อ login และการกระทบยอดค่ามากชนะระหว่างสองอุปกรณ์โดยไม่สูญเสีย
ข้อคิดสำคัญ
การ sync ไม่ใช่การคัดลอกข้อมูล แต่คือการกระทบยอดสองแหล่งความจริงที่แตกแยกกัน และวิธีเดียวที่จะไม่สูญเสียอะไรคือเลือกนโยบายความขัดแย้งอย่างชัดเจนและทำให้ operation idempotent ด้วย upsert บน natural key ถ้าคุณรัน sync สองครั้งติดกันด้วยผลลัพธ์เดียวกันไม่ได้ คุณไม่มี sync คุณมีระเบิดเวลา
ข้อผิดพลาดที่ควรเลี่ยง
- ×ลบ localStorage ก่อนยืนยันว่าการอัปโหลดขึ้น cloud สำเร็จ: ถ้า upsert ล้มเหลว ข้อมูลก็สูญหายตลอดกาล
- ×sync ที่ไม่ idempotent โดยไม่มี upsert บน natural key: การรันที่ถูกขัดจังหวะแล้ว retry สร้างแถวซ้ำที่ปรากฏเป็นความเสียหายในเวลาที่ไม่เหมาะสม
- ×ปล่อยให้อุปกรณ์ที่มีสถานะเก่าทับ cloud ('local ชนะ') แทนการกระทบยอด: แล็ปท็อปลบสิ่งที่มือถือก้าวหน้าไว้
- ×ลาก user_id จากสถานะนิรนามแทนการผูกกับ auth.uid() ตอนขึ้น: policy WITH CHECK จะปฏิเสธมัน หรือแย่กว่านั้น คุณจะพยายามเขียนใต้ identity ที่ไม่ใช่ของจริง
- ×แสดงเครื่องหมายถูกสีเขียวว่า 'sync แล้ว' ทั้งที่การอัปโหลดล้มเหลว: ผู้ใช้ไว้ใจ ปิดแท็บ และสูญเสียงานโดยไม่รู้ตัว
DS-06 Advisors, migration และการตรวจสอบ
บทเรียน ดำเนินงานฐานข้อมูลด้วยการเปลี่ยนแปลงที่มี version ควบคุมผ่าน migration และใช้ advisors ของ Supabase เป็น linter ด้านความปลอดภัยอย่างต่อเนื่องที่เตือนคุณเรื่อง table ที่ไม่มี RLS, ฟังก์ชันที่ไม่มี search_path และรูรั่วอื่นๆ ก่อนที่มันจะไปถึง production
Advisors, migration และการตรวจสอบ
บทเรียนดำเนินงานฐานข้อมูลด้วยการเปลี่ยนแปลงที่มี version ควบคุมผ่าน migration และใช้ advisors ของ Supabase เป็น linter ด้านความปลอดภัยอย่างต่อเนื่องที่เตือนคุณเรื่อง table ที่ไม่มี RLS, ฟังก์ชันที่ไม่มี search_path และรูรั่วอื่นๆ ก่อนที่มันจะไปถึง production
MOONKEY แชร์ฐานข้อมูลกับ XHUB IRON การเปลี่ยนแปลงที่สะเพร่าอาจไปทับ table ของโปรเจกต์อื่น หรือทิ้ง table ใหม่ไว้โดยไม่มี RLS เปิดโล่งต่อ anon key หากปราศจาก migration ที่มี version ควบคุม ก็ไม่มีทางรู้ว่าอะไรเปลี่ยนหรือย้อนกลับมัน หากปราศจาก advisors คุณจะค้นพบรูรั่วด้านความปลอดภัยตอนที่มีคน exploit มันไปแล้ว นี่คือวินัยที่ทำให้ทุกอย่างก่อนหน้านี้ยังคงซื่อตรง
บทเรียน
migration: ฐานข้อมูลในฐานะโค้ดที่มี version ควบคุม ทุกการเปลี่ยน schema ไม่ว่าจะเป็น table, column, policy หรือฟังก์ชัน คือไฟล์ .sql ที่มี timestamp ใน supabase/migrations/ นำไปใช้ด้วย apply_migration ไม่ใช่ execute_sql แบบทำมือ ความต่างนั้นเหมือนกับความต่างระหว่างการ commit กับการแก้ไฟล์บน production ผ่าน SSH อย่างหนึ่งให้ประวัติ การ review และ rollback อีกอย่างให้อาการความจำเสื่อม ชื่อไฟล์ (`<timestamp>_create_proofs.sql`, `<timestamp>_add_proofs_rls.sql`) เล่าเรื่องราวของ schema เมื่ออีกหกเดือนข้างหน้าคุณสงสัยว่าทำไม column หนึ่งถึงมีอยู่ คำตอบอยู่ใน migration ที่นำมันเข้ามา พร้อมชื่อและวันที่ ไม่ใช่ในความทรงจำของคุณหรือของใคร
execute_sql ไว้ อ่าน apply_migration ไว้ เปลี่ยน กฎเชิงปฏิบัตินี้ป้องกันความผิดพลาดที่พบบ่อยที่สุด ใช้ execute_sql เพื่อตรวจสอบ (select, explain, เช็คสถานะ), เพื่อสวมรอยและตรวจสอบ RLS (DS-02), เพื่อสำรวจ ณ ขณะที่ SQL เปลี่ยนแปลง schema หรือ policy ในแบบที่คุณต้องการให้คงอยู่ มันต้องอยู่ใน migration การเปลี่ยนความปลอดภัยที่นำไปใช้ด้วย execute_sql ที่ทำงานได้แต่ไม่มี version ควบคุมคือหนี้: ไม่มีใครรู้ว่ามันมีอยู่ ไม่มีใคร review มันได้ และเมื่อสร้างโปรเจกต์ขึ้นใหม่มันก็หายไป
branch ของ Supabase เพื่อไม่ให้ production พัง ก่อนนำ migration ที่มีผลกระทบไปใช้ ให้สร้างมันใน branch (create_branch), ทดสอบที่นั่น รวมถึงการตรวจสอบด้วยการสวมรอยของ DS-02 และ get_advisors ของ DS-04 แล้วจึงค่อย merge ไปยัง production (merge_branch) branch มีฐานข้อมูลชั่วคราวของตัวเอง คุณทำพังอะไรก็ได้โดยไม่แตะผู้ใช้จริง สิ่งนี้สำคัญยิ่งใน MOONKEY เพราะฐานข้อมูลเป็นแบบแชร์: migration ที่เผลอไปแตะ table iron_* หรือ world_* ของ XHUB จะถูกทดสอบและทิ้งไปใน branch ไม่ใช่ในฐานข้อมูลที่มีชีวิตซึ่งให้บริการสองโปรเจกต์
advisors คือ linter ด้านความปลอดภัยของคุณ get_advisors(type='security') รันชุดการตรวจสอบที่ระบุรูรั่วที่โมดูลเหล่านี้สอนให้หลีกเลี่ยงพอดี: table ที่ RLS ปิดอยู่ (DS-02), ฟังก์ชัน SECURITY DEFINER ที่มี search_path mutable (DS-04), policy ที่เปิดเผยข้อมูล, column ที่ไม่มีการป้องกัน get_advisors(type='performance') ระบุอีกด้าน: foreign key ที่ไม่มี index, index ซ้ำ, query ที่ไม่ครอบคลุม วินัยที่ต่อรองไม่ได้จาก CLAUDE.md ของ MOONKEY: 'การเปลี่ยน RLS/ความปลอดภัย: นำไปใช้เป็น migration ที่มี version ควบคุม + get_advisors หลังจากนั้น' ทุกครั้งที่คุณแตะความปลอดภัย advisor คือการปิดวงจร คุณไม่สันนิษฐานว่ามันถูก คุณตรวจสอบมันด้วยเครื่องมือ
วิธีอ่าน advisor และลงมือทำ warning ของ advisor ไม่ใช่เสียงรบกวนที่ต้องปิด แต่คือช่องโหว่ที่เฉพาะเจาะจงพร้อมวิธีแก้ที่เฉพาะเจาะจง 'RLS disabled on public.proofs' หมายความว่าใครก็ตามที่มี anon key อ่าน table ได้ วิธีแก้คือ enable row level security + policies ใน migration 'Function public.is_admin has a role mutable search_path' หมายความว่าฟังก์ชันถูกยึดได้ วิธีแก้คือ `alter function ... set search_path = ''` ใน migration กิจวัตรที่เป็นผู้ใหญ่คือ: คุณแตะอะไรบางอย่าง → migration → get_advisors → ถ้ามี warning → migration อีกอันที่ปิดมัน → get_advisors สะอาด ไม่มี 'ไว้แก้ทีหลัง' ใน advisor ด้านความปลอดภัย เพราะทีหลังคือหลังการรั่ว
การแยกกันในฐานข้อมูลที่แชร์ ความเสี่ยงเฉพาะของ MOONKEY ฐานข้อมูลบรรจุ table ของ MOONKEY (profiles, progress, feedback, leads, proofs) และของ XHUB IRON (iron_*, world_*, focus_*, daily_focus_history) วินัย migration ของคุณต้องเคารพเส้นแบ่งนั้น: migration ของ MOONKEY ต้องไม่มีวันเปลี่ยนแปลง table ของอีกโปรเจกต์ ไม่ว่าจะด้วยความสะเพร่าของ DROP หรือ ALTER ที่กว้างเกินไป ก่อนนำไปใช้ ให้อ่าน diff ของ migration เยี่ยงศัตรู: มันแตะเฉพาะ table ที่ฉันบอกหรือไม่? advisor และการ review SQL คือตาข่ายสองชั้น ในฐานข้อมูลที่แชร์ การเปลี่ยนแปลงที่ขอบเขตไม่ดีไม่ใช่ bug ของคุณ แต่เป็นเหตุการณ์ของอีกโปรเจกต์
การตรวจสอบในฐานะนิสัย ไม่ใช่เหตุการณ์ การตรวจสอบไม่ใช่สิ่งที่คุณทำก่อน launch แต่คือสถานะปริยายของการดำเนินงานข้อมูลอย่างจริงจัง list_migrations ให้ประวัติทั้งหมดว่า schema มาถึงจุดนี้ได้อย่างไร get_logs แสดงว่าอะไรกำลังล้มเหลวแบบเรียลไทม์ get_advisors คือการตรวจสุขภาพที่คุณรันหลังทุกการเปลี่ยนแปลงและเป็นระยะแม้คุณจะไม่เปลี่ยนอะไร (เพราะ Supabase เพิ่มการตรวจสอบใหม่ และเพราะบริบทเปลี่ยน) operator ที่ปฏิบัติต่อฐานข้อมูลเสมือนระบบที่มีชีวิตซึ่งถูกตรวจสอบอย่างต่อเนื่อง คือผู้ที่ไม่มีสายโทรตอนตีสาม เพราะเขาเห็น warning ใน branch เมื่อหนึ่งสัปดาห์ก่อนด้วย get_advisors
แบบฝึกหัด
นำ migration ของ RLS บน proofs ที่คุณเขียนใน DS-02 มา แต่คราวนี้ด้วยวินัยครบถ้วน: (1) สร้าง branch ของ moonkey-lab; (2) นำการสร้าง table และ policy ของมันไปใช้ใน branch นั้นในฐานะ migration ที่มี version ควบคุมแยกกันและตั้งชื่อเชิงพรรณนา; (3) รัน get_advisors(type='security') ก่อน วาง policy และยืนยันว่า warning 'RLS disabled' ปรากฏบน proofs; (4) นำ policy ไปใช้และรัน get_advisors อีกครั้ง ยืนยันว่า warning หายไป; (5) ตั้งใจใส่ฟังก์ชัน SECURITY DEFINER ที่ไม่มี search_path เข้าไป ตรวจสอบว่า advisor ระบุมัน แก้มันด้วย set search_path='' ในอีก migration และยืนยันว่า advisor สะอาด; (6) merge_branch ไป production เฉพาะเมื่อ advisor เป็นสีเขียว บันทึกรายการ migration สุดท้ายด้วย list_migrations
สิ่งที่ส่งมอบ
branch ที่มี migration ที่มี version ควบคุมและตั้งชื่อสำหรับ table+policies+ฟังก์ชัน พร้อมลำดับผลลัพธ์ของ get_advisors ที่แสดงวงจรปิด-warning: 'RLS disabled' ปรากฏ → หายไปหลัง policy, 'mutable search_path' ปรากฏ → หายไปหลังการแก้ไข และ get_advisors สุดท้ายที่สะอาดก่อน merge ไป production
ข้อคิดสำคัญ
advisors เปลี่ยนโมเดลความปลอดภัยของคุณจากสิ่งที่คุณ เชื่อ ว่าทำถูกแล้ว ให้เป็นสิ่งที่เครื่องมือ ยืนยัน ว่ามันถูก กฎของ operator ที่จริงจัง: ไม่มีการเปลี่ยนความปลอดภัยใดถือว่าเสร็จสิ้นจนกว่า get_advisors จะสะอาด เพราะต้นทุนของ warning ที่ถูกเพิกเฉยไม่ใช่ warning แต่คือการรั่วที่คุณค้นพบในทางที่เลวร้าย
ข้อผิดพลาดที่ควรเลี่ยง
- ×นำการเปลี่ยน schema หรือ policy ไปใช้ด้วย execute_sql แทน apply_migration: คุณสูญเสียประวัติ การ review และ rollback และการเปลี่ยนแปลงหายไปเมื่อสร้างโปรเจกต์ขึ้นใหม่
- ×แตะฐานข้อมูล production ที่แชร์โดยตรงแทนการทดสอบใน branch: ALTER หรือ DROP ที่กว้างเกินไปกลายเป็นเหตุการณ์ของ XHUB IRON
- ×ข้าม get_advisors หลังการเปลี่ยนความปลอดภัย: คุณทิ้ง table ที่ไม่มี RLS หรือฟังก์ชันที่ไม่มี search_path ที่ advisor จะระบุได้ในไม่กี่วินาทีให้มีชีวิตอยู่ต่อ
- ×ปฏิบัติต่อ warning ของ advisor เสมือนเสียงรบกวนที่ต้องปิดแทนที่จะเป็นช่องโหว่ที่มีวิธีแก้ที่เฉพาะเจาะจง: 'ไว้แก้ทีหลัง' ในเรื่องความปลอดภัยคือ 'แก้หลังการรั่ว'
- ×ไม่อ่าน diff ของ migration เยี่ยงศัตรูก่อนนำไปใช้ในฐานข้อมูลที่แชร์: การเปลี่ยนแปลงที่ขอบเขตไม่ดีไปทับ table iron_*/world_* ของอีกโปรเจกต์โดยที่คุณไม่รู้ตัว
กลุ่มดาวถัดไป
Builders
ส่งมอบผลิตภัณฑ์