Ops & Security
วินัย
ความต่างระหว่างโปรเจกต์กับเหตุการณ์ผิดพลาด ทั้งความลับ โมเดลภัยคุกคาม การตรวจสอบเชิงปฏิปักษ์ และการแยกขอบเขตระหว่างโปรเจกต์ วินัยที่ปกป้องทั้งกาแล็กซี
OS-01 ความลับ: สาธารณะกับส่วนตัว
บทเรียน ตัดสินใจให้ได้ว่า สำหรับ key หรือ token ใด ๆ ในโปรเจกต์ของคุณ มันสามารถอยู่ฝั่ง client ได้หรือไม่ หรือว่าจะต้องไม่ออกจาก server เด็ดขาด แล้วสร้าง decision tree ที่ป้องกันไม่ให้คุณทำผิดซ้ำอีก
ความลับ: สาธารณะกับส่วนตัว
บทเรียนตัดสินใจให้ได้ว่า สำหรับ key หรือ token ใด ๆ ในโปรเจกต์ของคุณ มันสามารถอยู่ฝั่ง client ได้หรือไม่ หรือว่าจะต้องไม่ออกจาก server เด็ดขาด แล้วสร้าง decision tree ที่ป้องกันไม่ให้คุณทำผิดซ้ำอีก
service_role key เพียงตัวเดียวที่รั่วเข้าไปใน bundle ของ Astro คือการเข้าถึงฐานข้อมูลของคุณได้อย่างสมบูรณ์โดยข้าม RLS ไปเลย นี่ไม่ใช่ bug ที่แค่ patch ก็จบ แต่เป็นช่องโหว่ที่บังคับให้คุณต้องหมุนเปลี่ยน credential ตรวจสอบการเข้าถึง และหากมีข้อมูลผู้ใช้ ก็ต้องแจ้งเตือนตามกฎหมาย ต้นทุนของการทำผิดตรงจุดนี้สูงที่สุดในทั้งกาแล็กซี
บทเรียน
เริ่มจากกฎที่ติดอยู่ในหัวและไม่เคยพลาด: ทุกสิ่งที่ถูก compile เข้าไปในเว็บไซต์แบบ static คือสิ่งสาธารณะ MOONKEY LAB เป็น Astro 4 ในโหมด SSG — build จะสร้าง HTML+JS แบบ flat ที่เสิร์ฟจาก Cloudflare Pages ไม่มี server คอยเก็บความลับไว้ใน runtime string ใด ๆ ที่คุณ import เข้าไปใน component .astro ที่ส่งถึง client จะไปจบลงในไฟล์ที่ใครก็ตามดาวน์โหลดได้ด้วย `view-source` หรือเปิด DevTools → Sources อย่างแท้จริง นี่คือเหตุผลที่ `src/lib/supabase.ts` มีแค่ anon key เท่านั้น anon key ถูกออกแบบมาเพื่อเป็นสาธารณะ: มันคือ identifier ของโปรเจกต์ + token ที่มีสิทธิ์ของ role `anon` ซึ่งอำนาจที่แท้จริงถูกจำกัดโดย Postgres RLS มันไม่ใช่ความลับ มันคือที่อยู่ที่มียามเฝ้าประตู
ตอนนี้คือความแตกต่างที่คุณต้องซึมซับให้ได้ มี key ของ Supabase สองตัวที่หน้าตาเหมือนกัน (เป็น JWT ยาว ๆ) แต่มีจักรวาลของอำนาจที่ตรงกันข้ามกัน `anon` key ทำงานภายใต้ role `anon`/`authenticated` และอยู่ใต้บังคับของ RLS: มันเห็นเฉพาะสิ่งที่ policies อนุญาตเท่านั้น ส่วน `service_role` key ทำงานด้วย `BYPASSRLS`: มันเพิกเฉยต่อ policies ทั้งหมด อ่านและเขียนแถวใดก็ได้ของตารางใดก็ได้ — รวมถึงตารางของ XHUB IRON (`iron_*`, `world_*`) ที่ใช้ instance `wuchsslgbqlhyxljsmxi` เดียวกันด้วย หาก `service_role` ปรากฏอยู่ใน client ผู้โจมตีไม่เพียงแค่เจาะ MOONKEY ได้ แต่ยังกระโดด (pivot) ไปยังทุกโปรเจกต์ที่อยู่ในฐานข้อมูลนั้นได้ กฎที่เด็ดขาด: `service_role` ห้ามเข้า repo ของ frontend เด็ดขาด ไม่ว่าจะใน `.astro` หรือใน `import.meta.env.PUBLIC_*` ที่ของมันคือ backend ที่มี session (เช่น Edge Function หรือ process ฝั่ง server) หรือ keyring บนเครื่องของคุณ — ไม่ใช่ใน bundle เด็ดขาด
vector ของการรั่วที่พบบ่อยที่สุดไม่ใช่การวาง key ลงในโค้ดตรง ๆ แต่คือ prefix ของ environment variable ใน Astro `import.meta.env.PUBLIC_X` จะถูก inline เข้าไปใน bundle ฝั่ง client ตอน build-time ส่วน `import.meta.env.X` (ไม่มี `PUBLIC_`) จะมีอยู่เฉพาะใน context ฝั่ง server/build เท่านั้นและจะ ไม่ ถูกเปิดเผย Vite/Astro ทำเช่นนี้โดยการออกแบบ ความผิดพลาดคลาสสิก: ตั้งชื่อ `PUBLIC_SUPABASE_SERVICE_KEY` 'เพื่อให้ component มองเห็นมัน' คุณทำได้สำเร็จ — และคุณก็ยกมันให้ทั้งโลกไปเลย decision tree ของคุณในการตั้งชื่อ env var: key นี้เป็นสาธารณะได้หรือไม่? ได้ → `PUBLIC_` ไม่ได้ → ไม่ใส่ prefix และยังต้องถามตัวเองด้วยว่ามันมาทำอะไรในโปรเจกต์ SSG (อาจจะไม่ควรอยู่ตรงนั้นเลยด้วยซ้ำ)
สร้าง inventory ขึ้นมา เปิดโปรเจกต์ของคุณแล้วจำแนก credential แต่ละตัวลงในตารางสามคอลัมน์: ชื่อ · ประเภท (สาธารณะ/ส่วนตัว) · ปัจจุบันอยู่ที่ไหน ใน MOONKEY รายการจริงคือ: `PUBLIC_SUPABASE_URL` (สาธารณะ, identifier), `PUBLIC_SUPABASE_ANON_KEY` (สาธารณะ, ถูกจำกัดโดย RLS) และ `service_role` (ส่วนตัว — ต้องไม่มีอยู่ใน repo นี้เลยโดยเด็ดขาด หากคุณต้องใช้มันสำหรับ script migration มันต้องอยู่ใน shell บนเครื่องของคุณหรือใน secret ของ CI ไม่ใช่ใน `src/` เด็ดขาด) เพิ่ม token ของบุคคลที่สามทุกตัวที่คุณแตะต้อง: key ของ transactional email, webhook, token ของ analytics ฝั่ง server แถวที่เป็นส่วนตัวทุกแถวที่อยู่ผิดฝั่งคือเหตุการณ์ฉุกเฉินที่รออยู่ว่าจะเกิดเมื่อไร
การควบคุมที่ปิดวงจรนี้คือการตรวจจับอัตโนมัติ เพราะวินัยของมนุษย์ย่อมพลาดได้ ก่อน commit ทุกครั้ง คุณต้องการให้มีบางอย่างส่งเสียงร้องเตือนหาก key ส่วนตัวเล็ดลอดเข้าไป pattern ขั้นต่ำคือ: grep ใน pre-commit ที่ค้นหา pattern ของ `service_role` (JWT ของ Supabase service มี `"role":"service_role"` อยู่ใน payload ที่เป็น base64) และคำอย่าง `service_role`, `BEGIN PRIVATE KEY`, `sk-` สำหรับ repo จริงให้ใช้ `gitleaks` หรือ `trufflehog` เป็น hook และหาก key ถูกรั่วไปแล้วใน commit ที่ผ่านมา: การลบมันออกจากโค้ดอย่างเดียวไม่พอ — มันยังคงอยู่ใน history ของ git ต้อง หมุนเปลี่ยน (rotate) มันใน panel ของ Supabase (Settings → API → roll key) และเขียน history ใหม่หากมันเป็นความลับที่ร้ายแรง การ rotate เป็นข้อบังคับ การลบไฟล์ไม่ได้เพิกถอนอะไรเลย
ปิดท้ายด้วยกรณีของ Espejo ซึ่งเพิ่มแกนของจริยธรรมเข้ามา ไม่ใช่แค่เรื่องเทคนิค Espejo เป็น multi-tenant: influencer-tenant แต่ละรายมีข้อมูลของตัวเอง ตรงนั้นคำถาม 'สาธารณะ vs ส่วนตัว' จะทวีคูณขึ้น: นอกจาก key ของ infrastructure แล้ว คุณยังจัดการข้อมูลส่วนบุคคล/ที่ได้รับความยินยอมของผู้ใช้ปลายทางด้วย key ของ service ที่อาจอ่านข้าม tenant ได้คือความลับที่อ่อนไหวที่สุดของผลิตภัณฑ์ — การรั่วของมันไม่ใช่แค่เรื่องเทคนิค แต่เป็นการละเมิดเส้นแบ่งข้อมูลที่ memory ของโปรเจกต์ระบุไว้ว่าต่อรองไม่ได้ บทเรียนข้ามทุกกรณี: credential ส่วนตัวไม่ได้นิยามด้วยรูปแบบของมัน แต่นิยามด้วยสิ่งที่มันปลดล็อกได้ จงวัดมันด้วย blast radius ของมันเสมอ
แบบฝึกหัด
ในโปรเจกต์ของคุณ (ใช้ MOONKEY หรือโปรเจกต์ของคุณเอง): 1) สร้าง `SECRETS.md` พร้อมตาราง inventory (ชื่อ · ประเภท · ที่อยู่ · blast radius) สำหรับ credential ทุกตัวที่โปรเจกต์แตะต้อง 2) รัน `grep -rn "service_role\|BEGIN.*PRIVATE KEY\|sk-" src/ .env* 2>/dev/null` และบันทึกว่าพบอะไร (โดยอุดมคติคือไม่พบอะไรเลยใน `src/`) 3) เขียน hook `.git/hooks/pre-commit` ที่ทำ grep นั้นกับไฟล์ที่ staged แล้ว (`git diff --cached --name-only`) และยกเลิกด้วย exit 1 หากพบ match 4) ตรวจสอบ hook โดยลอง commit ไฟล์ทดสอบที่มี string `"role":"service_role"` และยืนยันว่ามันบล็อกได้
สิ่งที่ส่งมอบ
`SECRETS.md` พร้อมตาราง inventory ที่ครบถ้วน (credential แต่ละตัวจำแนกตามประเภทและ blast radius) + hook `.git/hooks/pre-commit` ที่รันได้และบล็อก commit ที่มีความลับส่วนตัว ทดสอบกับเคสที่ควรจะล้มเหลวแล้ว
ข้อคิดสำคัญ
credential ไม่ได้เป็นความลับเพราะหน้าตาของมัน แต่เพราะสิ่งที่มันปลดล็อกได้: anon key กับ service_role ต่างก็เป็น JWT ที่มองดูเหมือนกันเป๊ะ แต่ตัวหนึ่งเป็นสาธารณะโดยการออกแบบ ขณะที่อีกตัวเจาะทั้งกาแล็กซีได้ จงจำแนกตาม blast radius เสมอ ไม่ใช่ตามรูปแบบ
ข้อผิดพลาดที่ควรเลี่ยง
- ×ใส่ prefix `PUBLIC_` ให้กับ key ส่วนตัว 'เพื่อให้ component อ่านมันได้' — Astro จะ inline มันเข้า bundle และเผยแพร่มันให้ทั้งโลก
- ×เชื่อว่าการลบ key ออกจากไฟล์คือการเพิกถอนมัน: มันยังคงอยู่ใน history ของ git และยังคงใช้งานได้จนกว่าคุณจะ rotate มันใน panel ของผู้ให้บริการ
- ×ปฏิบัติกับ anon key เหมือนเป็นความลับและทำให้มันยุ่งเหยิง (obfuscate): คุณเสียเวลาไปกับการปกป้องสิ่งที่เป็นสาธารณะ ในขณะที่อำนาจที่แท้จริง (RLS) กลับไม่ถูกตรวจสอบ
- ×สมมติว่าเว็บไซต์ SSG 'ไม่มีความลับที่ถูกเปิดเผย' โดยไม่ตรวจสอบ bundle ที่สร้างขึ้น: สิ่งที่สำคัญคือมีอะไรอยู่ใน `dist/` ไม่ใช่มีอะไรอยู่ใน `src/`
- ×ใส่ service_role เข้าไปใน script migration ที่อยู่ใน version control ของ repo แทนที่จะอ่านมันจาก shell บนเครื่องหรือจาก secret ของ CI ตอน runtime
OS-02 โมเดลภัยคุกคามของ SSG
บทเรียน คิดเหมือนผู้โจมตีเว็บไซต์ static ที่ใช้ anon key: ระบุพื้นผิวการโจมตี (attack surface) ที่แท้จริงของ SSG + Supabase และเข้าใจว่าทำไมการป้องกันเดียวที่มีค่าคือ RLS ไม่ใช่ฝั่ง client
โมเดลภัยคุกคามของ SSG
บทเรียนคิดเหมือนผู้โจมตีเว็บไซต์ static ที่ใช้ anon key: ระบุพื้นผิวการโจมตี (attack surface) ที่แท้จริงของ SSG + Supabase และเข้าใจว่าทำไมการป้องกันเดียวที่มีค่าคือ RLS ไม่ใช่ฝั่ง client
หลายคนปกป้องผิดประตู: ซ่อน panel admin ใน frontend แต่ปล่อยฐานข้อมูลเปิดโล่ง เว็บไซต์ static ไม่มี server คอย validate อะไรเลย — หาก threat model ของคุณไม่ได้เริ่มจาก 'client คือศัตรูและถูกควบคุมโดยผู้โจมตี' คุณก็จะสร้างการป้องกันแบบประดับตกแต่งที่ข้ามผ่านได้ด้วย curl
บทเรียน
จงรับเอากรอบความคิดที่ถูกต้องมา: ในระบบ SSG client ไม่ใช่แอปพลิเคชันของคุณ แต่เป็นดินแดนของศัตรู ผู้โจมตีมี bundle ทั้งหมดของคุณ (มันเป็นสาธารณะ) มี `PUBLIC_SUPABASE_URL` ของคุณ มี anon key ของคุณ และรู้ว่าเบื้องหลังมี Postgres อยู่ เขาไม่จำเป็นต้อง 'hack' JavaScript ของคุณ เขาแค่อ่านมัน เขาสามารถสร้าง Supabase client ของเขาเองด้วย anon key ของ คุณ จาก console ของ Node แล้วคุยกับฐานข้อมูลของคุณโดยตรง โดยไม่ต้องผ่าน HTML ของคุณเลย ดังนั้น logic ด้านความปลอดภัยทั้งหมดที่อยู่ใน `.astro` หรือใน JS ฝั่ง client — เช่น `if (user.role === 'admin')` หรือ component ที่ซ่อนตัวเอง — คือ UX ไม่ใช่ความปลอดภัย CLAUDE.md ของ MOONKEY ระบุไว้ตรง ๆ ว่า `admin.astro` คือ UX ความปลอดภัย ไม่ ขึ้นกับ gate นั้น
ระบุพื้นผิวการโจมตีที่เป็นรูปธรรม สำหรับ MOONKEY มีห้าตารางที่เข้าถึงได้ด้วย anon key: `profiles, progress, feedback, leads, proofs` ผู้โจมตีที่ authenticate ผ่าน magic-link (ใครก็ลงทะเบียนได้) จะลองสิ่งที่ชัดเจน: `supabase.from('profiles').select('*')` เพื่อ dump ทุก profile; `supabase.from('leads').select('*')` เพื่อขโมยรายชื่อ leads; ทำ `update` บนแถวของตัวเองใน `profiles` โดยตั้ง `role = 'admin'` หรือ `founder_badge = true` เพื่อยกระดับสิทธิ์ตัวเอง; ทำ `insert` ใน `proofs` ด้วย `user_id` ของคนอื่นเพื่อปลอม progress ของคนอื่น แต่ละข้อคือสมมติฐานการโจมตีที่ คุณ ต้องหักล้างด้วย policy หากคุณยังไม่ได้หักล้างมันอย่างชัดแจ้ง ให้สมมติว่ามันใช้งานได้
จงเข้าใจว่าทำไม RLS คืออำนาจ และการป้องกันที่แท้จริงของ MOONKEY ประกอบขึ้นมาอย่างไร SELECT บน `profiles/progress/feedback/proofs` เป็นแบบ self-or-admin: policy อนุญาตแถวนั้นก็ต่อเมื่อ `user_id = auth.uid()` หรือ `is_admin()` `is_admin()` เป็น SECURITY DEFINER — มันรันด้วยสิทธิ์ของเจ้าของ ตรวจสอบว่าผู้เรียกเป็น admin หรือไม่โดยที่ผู้เรียกไม่สามารถบิดเบือนการตัดสินนั้นได้ ผลลัพธ์: ผู้ที่ไม่ใช่ admin ที่ขอ `select('*')` จะไม่ได้รับ error แต่จะได้รับแถว ของตัวเอง และไม่มีอะไรมากกว่านั้น — การ dump จำนวนมากจะคืนค่ามาแค่แถวเดียว คือแถวของเขาเอง การยกระดับสิทธิ์ถูกตัดด้วย trigger (`guard_privileged_profile_columns`): `role` และ `founder_badge` ไม่สามารถเปลี่ยนแปลงได้สำหรับผู้ที่ไม่ใช่ admin ดังนั้น `update role='admin'` จึงถูกปฏิเสธในระดับฐานข้อมูล การ insert ของคนอื่นถูกตัดด้วย `proofs_insert_self`: มันบังคับให้มี session และผูก `user_id = auth.uid()`
ผลลัพธ์เชิงปฏิบัติของโมเดลนี้คือ: จงตรวจสอบด้วยการ impersonation ไม่ใช่ด้วยการอ่านโค้ด policy ที่ 'ดูเหมือน' ถูกต้องอาจมีรูโหว่อยู่ (OR ที่วางผิดที่ หรือ USING ที่ไม่มี WITH CHECK ซึ่งปล่อยให้ update ผ่านได้) การพิสูจน์เดียวที่มีค่าคือการสวมรองเท้าของผู้โจมตี: authenticate เป็นผู้ใช้ปกติแล้ว รัน การโจมตีที่ระบุไว้ข้างต้นกับฐานข้อมูลจริง ยืนยันว่าแต่ละอันคืนค่าว่างหรือ error CLAUDE.md ระบุว่าการแยกการอ่าน (read isolation) นั้น 'ตรวจสอบแล้วด้วยการ impersonation' — นั่นคือมาตรฐาน คำว่า 'ฉันเขียน policy แล้ว' ไม่ใช่หลักฐาน แต่คำว่า 'ฉันลองโจมตีในฐานะ user X แล้วและมันไม่อ่านแถวของ user Y' ต่างหากที่เป็นหลักฐาน
อย่าลืม vector ที่ไม่ใช่ RLS threat model ของ SSG ยังรวมถึง: (a) ความแตกต่างระหว่าง USING vs WITH CHECK — USING กรองว่าคุณเห็น/กระทบแถวไหน ส่วน WITH CHECK validate สถานะผลลัพธ์ของ INSERT/UPDATE; การลืม WITH CHECK ใน UPDATE ปล่อยให้คุณย้ายแถวไปยัง user_id ของคนอื่นได้ (b) RPC: ฟังก์ชัน SECURITY DEFINER ที่เขียนไม่ดีคือรูโหว่ที่เพิกเฉยต่อ RLS โดยการออกแบบ — MOONKEY จำกัดมันไว้แค่ `authenticated` และเพิกถอน operation ที่อันตราย (c) TRUNCATE: ถูกเพิกถอนจาก anon/authenticated เพราะ RLS ไม่ได้ป้องกัน TRUNCATE (มันลบทั้งตารางโดยไม่ประเมิน policy ทีละแถว) (d) การรั่วผ่าน error และผ่านคอลัมน์: SELECT ที่คืนคอลัมน์ที่อ่อนไหวซึ่งคุณลืม exclude ออก
ลงจอดด้วยการเปรียบเทียบโปรเจกต์เพื่อตอกย้ำหลักการ XNLAB เป็นเว็บไซต์แบรนด์ที่แทบไม่มี backend: พื้นผิวของมันน้อยมาก — ฟอร์มติดต่อ header บางที endpoint รับสมัคร ตรงนั้น threat model คือ 'spam และ XSS ในส่วนเล็ก ๆ ที่มันรับ input' ไม่ใช่การยกระดับสิทธิ์ในฐานข้อมูล XHUB IRON อยู่ในฐานข้อมูล เดียวกัน กับ MOONKEY แต่เป็น panel ปฏิบัติการของผู้ก่อตั้ง: ตาราง `iron_*` ของมันต้องไม่สามารถอ่านได้โดยผู้ใช้ MOONKEY — เครื่องยนต์ RLS เดียวกันต้องรักษาผลิตภัณฑ์ สองตัว ที่ใช้ Postgres ร่วมกันโดยที่ตัวหนึ่งไม่อ่านอีกตัว (นั่นคือ OS-05) threat model ไม่ใช่สิ่งทั่ว ๆ ไป: มันถูกอนุมานมาจากว่ามี ข้อมูลอะไร ใคร authenticate ได้ และอะไรที่ใช้ infrastructure ร่วมกัน จงเริ่มจากการทำ inventory ของสามแกนนี้เสมอ
แบบฝึกหัด
บน MOONKEY (หรือโปรเจกต์ Supabase ของคุณ): 1) เขียน `THREAT-MODEL.md` โดยระบุสำหรับแต่ละตารางที่เข้าถึงได้ด้วย anon key ว่า ผู้ใช้ที่ authenticate แล้วและมีเจตนาร้ายจะลองโจมตีอะไรบ้าง (dump จำนวนมาก, ยกระดับสิทธิ์, เขียนทับแถวของคนอื่น) 2) สำหรับแต่ละการโจมตี ให้บันทึกการป้องกันที่ ควร จะหยุดมันได้ (policy/trigger ตัวไหน) แล้วทำเครื่องหมายว่า 'ตรวจสอบแล้ว' หรือ 'ยังไม่ตรวจสอบ' 3) เขียน snippet ของการ impersonation ที่คุณจะรันเพื่อทดสอบการ dump ของ `profiles` ในฐานะผู้ใช้ปกติ (client ด้วย anon key + session ของ user ทดสอบ ทำ `.from('profiles').select('*')`) แล้วทำนายผลลัพธ์ที่คาดหวัง (แค่ 1 แถว)
สิ่งที่ส่งมอบ
`THREAT-MODEL.md`: ตารางการโจมตี→การป้องกัน→สถานะการตรวจสอบ ที่ครอบคลุมทุกตารางที่ถูกเปิดเผยโดย anon key รวมถึง snippet การ impersonation ที่เป็นรูปธรรมสำหรับการโจมตีแบบ dump จำนวนมากอย่างน้อยหนึ่งรายการ พร้อมผลลัพธ์ที่คาดหวัง
ข้อคิดสำคัญ
ในระบบ SSG client คือดินแดนของผู้โจมตี: เขามี bundle ของคุณ มี anon key ของคุณ และคุยกับ Postgres ของคุณโดยตรงผ่าน curl โดยไม่แตะ HTML ของคุณเลย การตรวจสอบความปลอดภัยทุกอย่างที่อยู่ใน .astro คือ UX; เส้นแบ่งที่แท้จริงเดียวคือ RLS และมันจะมีค่าก็ต่อเมื่อคุณได้หักล้างมันด้วยการ impersonation ไม่ใช่ด้วยการอ่านมัน
ข้อผิดพลาดที่ควรเลี่ยง
- ×สับสนระหว่างการซ่อน panel admin ใน frontend กับการปกป้องมัน: `admin.astro` ซ่อน UI แต่ฐานข้อมูลยังคงเข้าถึงได้ผ่าน curl ด้วย anon key
- ×ตรวจสอบ policy ด้วยการอ่านมันแทนที่จะโจมตีมันในฐานะผู้ใช้ที่ถูก impersonate — OR ที่วางผิดที่จะเห็นได้ก็ต่อเมื่อรันการโจมตีจริง ๆ
- ×เขียน UPDATE ด้วย USING แต่ไม่มี WITH CHECK ทำให้สามารถย้ายแถวไปยัง `user_id` ของคนอื่นได้ แม้ policy จะ 'ดูเหมือน' ปกป้องอยู่ก็ตาม
- ×ลืมว่า TRUNCATE และ RPC แบบ SECURITY DEFINER หลบเลี่ยง RLS: การปกป้องมันต้องใช้ REVOKE อย่างชัดเจน แค่มี policy ระดับแถวไม่เพียงพอ
- ×นำ threat model ทั่ว ๆ ไปที่ลอกมาจาก tutorial มาใช้แทนที่จะอนุมานมันจากสามแกนที่แท้จริงของคุณ: มีข้อมูลอะไร ใคร authenticate และใช้ infrastructure อะไรร่วมกัน
OS-03 การตรวจสอบเชิงปฏิปักษ์
บทเรียน ตรวจสอบ (audit) แบบ adversarial: ปฏิบัติต่อทุกข้อค้นพบด้านความปลอดภัยเหมือนเป็นสมมติฐานที่คุณต้องหักล้างก่อนจะเชื่อมัน แทนที่จะยอมรับว่า 'ดูเหมือนมีช่องโหว่' หรือ 'ดูเหมือนปลอดภัย' จากการตรวจดูเฉย ๆ
การตรวจสอบเชิงปฏิปักษ์
บทเรียนตรวจสอบ (audit) แบบ adversarial: ปฏิบัติต่อทุกข้อค้นพบด้านความปลอดภัยเหมือนเป็นสมมติฐานที่คุณต้องหักล้างก่อนจะเชื่อมัน แทนที่จะยอมรับว่า 'ดูเหมือนมีช่องโหว่' หรือ 'ดูเหมือนปลอดภัย' จากการตรวจดูเฉย ๆ
อคติยืนยัน (confirmation bias) ทำลายการ audit: scanner บอกว่า 'RLS ปิดอยู่' แล้วคุณก็แตกตื่น หรือคุณบอกว่า 'policy ดูดีนะ' แล้วเซ็นอนุมัติไป ทั้งสองความผิดพลาดมีราคาแพง — false positive เผาเวลาและความน่าเชื่อถือของคุณ; false negative ทิ้งช่องโหว่ไว้เปิดโล่ง วินัยในการหักล้างก่อนคือสิ่งที่แยกการ audit ที่แท้จริงออกจากความคิดเห็น
บทเรียน
หลักการชี้นำเป็นแบบ Popper: ข้อค้นพบด้านความปลอดภัยไม่ได้เป็นความจริงเพราะคุณยืนยันมัน แต่เป็นความจริงเพราะคุณ พยายามหักล้าง มันแล้วและทำไม่ได้ จงกลับทิศของกระแสตามธรรมชาติ เมื่อคุณคิดว่าพบช่องโหว่ ('ใครก็อ่าน `leads` ได้') ขั้นต่อไปของคุณไม่ใช่การรายงานมัน — แต่คือการพยายามพิสูจน์ว่าคุณคิดผิด: มี policy ที่ป้องกันมันไหม? ฉันทดสอบในฐานะ role ที่ถูกต้องหรือเปล่า? `select` คืนข้อมูลจริงหรือคืนค่าว่าง? เมื่อความพยายามหักล้างของคุณล้มเหลวเท่านั้น ข้อค้นพบจึงเลื่อนขั้นเป็นยืนยันได้ และในทางกลับกัน: เมื่อคุณคิดว่าบางอย่างปลอดภัย ('policy แบบ self-or-admin ปกป้อง `profiles`') งานของคุณคือโจมตีมันจนกว่ามันจะแตกหรือรอดมาได้
นิยามความผิดพลาดสองอย่างที่คุณไล่ล่าด้วยชื่อที่ชัดเจน False positive: คุณรายงานช่องโหว่ที่ไม่มีอยู่จริง — เช่น ตะโกนว่า 'anon key ถูกเปิดเผยใน bundle' ทั้งที่ anon key เป็นสาธารณะโดยการออกแบบและฐานข้อมูลถูกปกป้องด้วย RLS (ดู OS-01) คุณเผาความน่าเชื่อถือ; ครั้งหน้าจะไม่มีใครเชื่อคุณ False negative: คุณถือว่าบางอย่างปลอดภัยทั้งที่ไม่ปลอดภัย — เช่น 'panel admin ถูกซ่อนแล้ว เราโอเค' โดยไม่ทดสอบการเข้าถึงฐานข้อมูลโดยตรง คุณทิ้งช่องโหว่ไว้เปิดโล่ง การ audit แบบ adversarial มีอยู่เพื่อทำให้ทั้งสองอย่างน้อยที่สุด และเครื่องมือสำหรับทั้งสองคือสิ่งเดียวกัน: การทดสอบที่รันได้
วิธีการที่เป็นรูปธรรม: สำหรับแต่ละข้อค้นพบ จงสร้างหลักฐานที่ทำซ้ำได้ ไม่ใช่คำกล่าวอ้าง 'ทำซ้ำได้' หมายถึง: คำสั่งหรือ snippet ที่ใครก็รันได้และเห็นผลลัพธ์เดียวกัน เพื่อยืนยันว่า `profiles` dump ไม่ได้: script ที่ authenticate ในฐานะ user_A ทำ `.from('profiles').select('*')` แล้วพิมพ์จำนวนแถว — คาดหวัง 1 (แค่แถวของตัวเอง) เพื่อยืนยันว่าการยกระดับสิทธิ์ถูกปิด: ในฐานะ user_A ทำ `.from('profiles').update({ role: 'admin' }).eq('id', myId)` แล้วพิมพ์ error ที่ trigger guard คืนมา หากการโจมตีคืนสิ่งที่คุณคาดหวัง (ว่าง/error) แสดงว่าการควบคุมได้รับการตรวจสอบแล้ว หากมันคืนข้อมูลมา คุณมีข้อค้นพบที่ จริง — และมันมาพร้อม PoC แนบมาด้วยแล้ว
ทำงานกับเครื่องมือของ Supabase เองแต่อย่ายอมจำนนต่อมัน `get_advisors` (security lints) ชี้ให้คุณเห็นตารางที่ไม่มี RLS, policy ที่หละหลวม, ฟังก์ชัน SECURITY DEFINER ที่ไม่ได้กำหนด search_path ตายตัว จงปฏิบัติต่อมันในฐานะ ตัวสร้างสมมติฐาน ไม่ใช่ตัวตัดสิน advisor ที่บอกว่า 'ฟังก์ชัน X เป็น SECURITY DEFINER mutable search_path' คือสมมติฐานความเสี่ยง: ไปดูฟังก์ชันนั้น ดูว่าผู้โจมตีสามารถวาง object ลงใน schema ที่มันจะ resolve ก่อนได้หรือไม่ บางครั้งใช้ประโยชน์ได้ บางครั้งไม่ได้ขึ้นกับ context advisor ช่วยประหยัดการกวาดตรวจรอบแรกให้คุณ; การหักล้างคุณต้องทำเอง CLAUDE.md ทำให้มันเป็นกิจวัตรอยู่แล้ว: การเปลี่ยน RLS = migration ที่มี version + `get_advisors` หลังจากนั้น ส่วนที่สองนั้นคือการ audit แบบ adversarial ที่กลายเป็นกิจวัตร
ระวัง false negative ของวิธีการเองด้วย: การโจมตีที่ 'ผ่าน' อาจผ่านด้วยเหตุผลที่ผิด หาก `select('*')` ของคุณบน `profiles` คืนค่าว่าง มันเป็นเพราะ RLS บล็อกมัน หรือเพราะ session ทดสอบของคุณหมดอายุ / คุณไม่ได้ authenticate / ตารางว่างเปล่า? การควบคุมจะไม่ถือว่าใช้ได้หากไม่มีเคส เปรียบเทียบ (control): ทดสอบว่า query เดียวกันนั้น คืนข้อมูลได้จริง เมื่อมันควรคืน (ในฐานะ admin หรือในฐานะเจ้าของแถว) การทดสอบความปลอดภัยที่ไม่มี control เชิงบวกของมันคือ false negative ที่ปลอมตัวมาในคราบของความสำเร็จ จงตรวจสอบเสมอว่าอาวุธของคุณยิงได้ก่อนจะสรุปว่าเป้าหมายมีภูมิคุ้มกัน
นำความเข้มงวดนี้ไปลงจอดในโปรเจกต์ที่การทำผิดเจ็บที่สุด ใน XCAP การ audit แบบ adversarial เป็นหลักคำสอนหลัก: memory ของโปรเจกต์อธิบายมันว่าเป็น 'anti-fabrication by construction' — ระบบที่บันทึก forecast แล้ว calibrate กับผลลัพธ์จริง หักล้างคำทำนายของตัวเองแทนที่จะเชื่อมัน กล้ามเนื้อชุดเดียวกันนั้นคุณนำไปใช้กับความปลอดภัย: อย่าเซ็นรับรองว่า 'ledger เป็น read-only' จนกว่าความพยายามเขียนด้วย credential ของ ingest จะล้มเหลวต่อหน้าต่อตาคุณ ใน Espejo ที่เส้นแบ่งข้อมูลส่วนบุคคลเป็นเรื่องจริยธรรมนอกเหนือจากเทคนิค การ audit ที่บอกว่า 'ไม่มีการรั่วระหว่าง tenant' โดยไม่มี PoC ของ tenant หนึ่งที่พยายามอ่านอีก tenant นั้น ไม่ใช่ การ audit: มันคือความปรารถนา จงหักล้างก่อนเสมอ โดยเฉพาะเมื่อผลลัพธ์ที่คุณคาดหวังคือผลที่สบายใจ
แบบฝึกหัด
เลือกการควบคุมความปลอดภัยจริงของ MOONKEY (เช่น 'ผู้ที่ไม่ใช่ admin อ่าน profile ของคนอื่นไม่ได้') 1) ตั้งมันเป็นสมมติฐานที่หักล้างได้ 2) เขียนการทดสอบ สอง อย่าง: อันที่เป็นการโจมตี (ในฐานะ user_A ฉันพยายามอ่านแถวของ user_B → คาดว่าได้ค่าว่าง) และอันที่เป็นการเปรียบเทียบ (ในฐานะ user_A ฉันอ่านแถวของตัวเอง → คาดว่าได้ 1 แถว; หรือในฐานะ admin ฉันอ่านทั้งหมด → คาดว่าได้ N) 3) รัน `get_advisors` (หรือบันทึกว่ามันจะชี้อะไร) แล้วสำหรับแต่ละ lint จำแนกว่าเป็น 'สมมติฐานที่ยืนยันแล้วว่าเป็นความเสี่ยงจริง' หรือ 'ถูกหักล้าง (ใช้ประโยชน์ไม่ได้เพราะ...)' 4) เขียนรายงานสั้น ๆ ที่แต่ละคำกล่าวอ้างมีคำสั่งที่ทำซ้ำได้กำกับอยู่
สิ่งที่ส่งมอบ
`AUDIT.md`: รายงานที่แต่ละข้อค้นพบ (มีช่องโหว่หรือปลอดภัย) มี PoC ที่ทำซ้ำได้กำกับ — การโจมตี + เคสเปรียบเทียบเชิงบวก — และที่แต่ละ lint ของ `get_advisors` ถูกทำเครื่องหมายว่ายืนยันแล้วหรือถูกหักล้างพร้อมเหตุผล
ข้อคิดสำคัญ
การทดสอบความปลอดภัยที่ 'ผ่าน' โดยไม่มีเคสเปรียบเทียบเชิงบวกคือ false negative ที่ปลอมตัวมา: select ที่ได้ค่าว่างอาจเกิดจาก RLS หรือจาก session ที่หมดอายุก็ได้ จงตรวจสอบเสมอว่าอาวุธของคุณยิงออกก่อนจะประกาศว่าเป้าหมายมีภูมิคุ้มกัน — หลักการหักล้างก่อนใช้กับวิธีการของคุณเองด้วย
ข้อผิดพลาดที่ควรเลี่ยง
- ×รายงานข้อค้นพบจากการตรวจดูโค้ดโดยไม่มี PoC ที่รันได้: 'policy ดูอ่อนแอ' ไม่ใช่ข้อค้นพบ แต่เป็นลางสังหรณ์
- ×ปฏิบัติต่อ lint ของ `get_advisors` เหมือนเป็นคำตัดสินแทนที่จะเป็นสมมติฐานที่ต้องหักล้าง — มันสร้าง false positive ที่หากเกิดซ้ำ ๆ จะเผาความน่าเชื่อถือของคุณ
- ×ถือว่าการควบคุมปลอดภัยเพราะการโจมตีคืนค่าว่าง โดยไม่ทดสอบว่า query เดียวกัน คืนข้อมูลได้จริง เมื่อมันควรคืน (control เชิงบวก)
- ×ตะโกนว่า 'anon key ถูกเปิดเผย' ในฐานะช่องโหว่ทั้งที่มันเป็นสาธารณะโดยการออกแบบ: false positive คลาสสิกที่ฟ้องว่ายังไม่เข้าใจโมเดลความปลอดภัย
- ×audit เฉพาะในทิศทางที่สบายใจ (ยืนยันสิ่งที่คุณคาดหวัง) แทนที่จะโจมตีการควบคุมที่คุณคิดว่าปลอดภัยให้หนักเท่ากัน
OS-04 การรับมือเหตุการณ์และ rollback
บทเรียน ตอบสนองต่อ incident ใน production ด้วยขั้นตอนที่เยือกเย็น: ตรวจจับ, ควบคุม, ทำ rollback อย่างปลอดภัย, และดำเนินการ post-mortem แบบไม่โทษใคร (blameless) ที่ปิดสาเหตุที่แท้จริง
การรับมือเหตุการณ์และ rollback
บทเรียนตอบสนองต่อ incident ใน production ด้วยขั้นตอนที่เยือกเย็น: ตรวจจับ, ควบคุม, ทำ rollback อย่างปลอดภัย, และดำเนินการ post-mortem แบบไม่โทษใคร (blameless) ที่ปิดสาเหตุที่แท้จริง
เมื่อบางอย่างพังใน production สัญชาตญาณ (ด้นสด, แก้ DB ตอนกำลังร้อน, ปกปิด error) มักทำให้ความเสียหายแย่ลง ความแตกต่างระหว่างความตกใจสิบนาทีกับสุดสัปดาห์ที่หายไป คือการมี runbook ที่เขียนไว้ ก่อน เกิด incident หากไม่มีมัน rollback ที่ทำผิดจะทำลายข้อมูลที่ bug แค่ทำให้เสียหาย
บทเรียน
ซึมซับลำดับ DCRP: Detect (ตรวจจับ) → Contain (ควบคุม) → Recover (rollback) → Post-mortem ความผิดพลาดของคนส่วนใหญ่คือกระโดดตรงไปที่ 'แก้ไข' ด้วยการแตะ production แบบมืดบอด ลำดับสำคัญเพราะแต่ละเฟสปกป้องเฟสถัดไป: หากตรวจจับไม่ดี คุณจะควบคุมสิ่งที่ผิด; หากไม่ควบคุม ความเสียหายจะลามขึ้นในขณะที่คุณกู้คืน; หากกู้คืนโดยไม่เข้าใจ คุณจะทำให้ incident เกิดซ้ำ และกฎข้อศูนย์ที่เขียนไว้ใน runbook ที่จริงจังทุกอัน: ใน incident คุณ ไม่ ด้นสดบน production คุณรันขั้นตอนที่กำหนดไว้ล่วงหน้า ความคิดสร้างสรรค์มีไว้สำหรับ post-mortem ไม่ใช่สำหรับตอนตีสามขณะที่เว็บไซต์ล่ม
การตรวจจับคือการรู้ว่า อะไร พังและพังตั้งแต่ เมื่อไร ใน MOONKEY (Cloudflare Pages + Supabase) สัญญาณของคุณคือ: deploy ของ Cloudflare (commit ไหน live อยู่? build ล่าสุดผ่านไหม?), logs ของ Supabase (`get_logs` แยกตาม service: api, postgres, auth — ตรงนั้นคุณเห็น error ของ policy, query ที่ล้มเหลว, จุดพีคของ 4xx/5xx), และข้อร้องเรียนของผู้ใช้ (มักเป็นสัญญาณแรก) สิ่งแรกที่คุณกำหนดคือ timeline: อะไรเปลี่ยนไปก่อนหน้านั้นพอดี? เกือบทุกครั้ง incident จะสัมพันธ์กับ deploy หรือ migration ที่เพิ่งทำ ประโยค 'มันเริ่มหลัง merge ตอน 16:20' ตีกรอบพื้นที่ค้นหาจากหลายชั่วโมงให้เหลือไม่กี่นาที
การควบคุมคือการหยุดเลือดก่อนรักษาแผล คำถามสำคัญ: สิ่งนี้กำลังทำให้ข้อมูลเสียหาย อยู่ตอนนี้ หรือเปล่า? หาก bug กำลังเขียนแถวผิด ๆ ลง `proofs` หรือ `progress` ทุกนาทีที่คุณช้าจะทวีคูณงาน cleanup ตัวเลือกการควบคุมตามความรุนแรง: revert deploy ของ Cloudflare กลับไปอันก่อนหน้า (คลิกเดียว — Cloudflare Pages เก็บ deploy ก่อนหน้าไว้และอนุญาตให้ rollback ได้ทันที นี่คือคันโยกที่เร็วและปลอดภัยที่สุดของคุณ), ปิด feature ที่พัง, หรือในกรณีร้ายแรงที่เป็นการเขียนแบบทำลายล้าง ให้ตัดการเข้าถึงในระดับ policy การควบคุม ไม่ใช่ การแก้ไข: มันคือการแช่แข็งความเสียหายไว้ในสถานะปัจจุบันเพื่อมีเวลาคิด
การกู้คืนต้องแยก rollback สองแบบ ที่คนสับสนและมีความเสี่ยงตรงกันข้าม (1) Rollback ของ โค้ด: กลับไปยัง deploy/commit ก่อนหน้า มันราคาถูก ย้อนกลับได้ และเกือบทุกครั้งปลอดภัย — ใน Cloudflare Pages มันทำได้ทันที นี่คือตัวเลือกแรกของคุณ (2) Rollback ของ schema/ข้อมูล: ย้อน migration หรือ restore ข้อมูล มัน อันตราย และย้อนกลับไม่ได้: `down` migration ที่ทำ `DROP COLUMN` ลบข้อมูลที่อาจแค่เสียหาย ไม่ได้สูญหาย กฎ: อย่ารัน rollback ของข้อมูลโดยไม่มี backup/PITR ที่ยืนยันแล้วก่อน; ให้เลือก migration แบบ forward ที่แก้ไข (เช่น `UPDATE` ใหม่ที่ซ่อมแถวผิด) มากกว่า `down` แบบทำลายล้าง; ทุก migration ของ Supabase มี version (`apply_migration`) เพื่อให้สถานะสามารถสร้างขึ้นใหม่ได้พอดี หากลังเลว่า 'rollback ของโค้ดหรือของข้อมูล' ให้เริ่มจากของโค้ด เสมอ: บ่อยครั้งมันก็พอแล้วและไม่แตะ DB
post-mortem คือที่ที่ incident จ่ายหนี้ของมันด้วยการกลายเป็นบทเรียน มันต้อง ไม่โทษใคร (blameless): เป้าหมายไม่ใช่ว่าใครทำพัง แต่คืออะไรใน ระบบ ที่ปล่อยให้มนุษย์ปกติทำพังได้ โครงสร้าง: timeline (อะไรเกิดขึ้นและเมื่อไร นาทีต่อนาที), ผลกระทบ (ผู้ใช้กี่คน ข้อมูลอะไร นานแค่ไหน), สาเหตุที่แท้จริง ('5 ทำไม' จนถึงความล้มเหลวของระบบ ไม่ใช่ของคน), และการแก้ไขเชิงปฏิบัติพร้อมเจ้าของและกำหนดเวลา ทุก incident ต้องสร้างการควบคุมอย่างน้อยหนึ่งอย่างที่จะ ป้องกัน หรือ ตรวจจับ มันได้เร็วกว่าเดิม — test, advisor ใน CI, หรือ alert หาก post-mortem ไม่เปลี่ยนอะไรในระบบเลย มันก็ไม่ใช่ post-mortem แต่เป็นคำสารภาพ
ยึด runbook ไว้กับความเป็นจริงของแต่ละโปรเจกต์ เพราะความรุนแรงและคันโยกเปลี่ยนไป ใน XCAP มี invariant ของทุนที่ศักดิ์สิทธิ์ — ทุนสะสมข้ามแต่ละ session และเคลื่อนไหวเฉพาะตอนปิดที่ realize แล้ว ไม่เคยรีเซ็ต incident ที่แตะ invariant นั้น ไม่ ได้แก้ด้วย rollback ของข้อมูลแบบมืดบอด: คุณจะลบประวัติบัญชีจริงไป ตรงนั้น runbook บังคับให้แก้แบบ forward และตรวจสอบ invariant ก่อนแตะอะไรทั้งสิ้น ใน Espejo ที่เป็น multi-tenant incident มีคำถามพิเศษเพิ่มในเฟสตรวจจับ: ความล้มเหลวข้ามเส้นแบ่งระหว่าง tenant ไปหรือไม่? หาก bug เปิดเผยข้อมูลของ tenant หนึ่งให้อีก tenant นอกจาก rollback แล้วยังมีหน้าที่ต้องควบคุมการรั่ว runbook ทั่วไปให้กระดูกสันหลังแก่คุณ; invariant ของแต่ละโปรเจกต์ให้กฎที่คุณ ไม่เคย ละเมิดในยามตื่นตระหนก
แบบฝึกหัด
เขียน `RUNBOOK.md` สำหรับ incident ของ MOONKEY: 1) ส่วน Detect (คุณดูคำสั่ง/panel อะไร: deploy ของ Cloudflare, `get_logs` ของ Supabase แยกตาม service, คุณกำหนด timeline อย่างไร) 2) ส่วน Contain พร้อมคันโยก rollback ของ Cloudflare Pages ทีละขั้น 3) decision tree 'rollback ของโค้ด vs rollback ของข้อมูล' พร้อมกฎ 'เริ่มจากโค้ด ข้อมูลทำได้เฉพาะเมื่อยืนยัน PITR แล้ว' 4) template ของ post-mortem แบบ blameless (timeline / ผลกระทบ / 5-ทำไม / การแก้ไขเชิงปฏิบัติพร้อมเจ้าของ) 5) จำลอง incident: 'หลัง deploy ตอน 16:20 ผู้ใช้บันทึก proofs ไม่ได้' แล้วเขียนการตอบสนองทีละขั้นตามด้วย runbook ของคุณเอง
สิ่งที่ส่งมอบ
`RUNBOOK.md` พร้อมสี่เฟส DCRP ที่ทำให้ใช้งานได้จริงสำหรับ Cloudflare Pages + Supabase, decision tree โค้ด-vs-ข้อมูล, template post-mortem แบบ blameless, และ incident จำลองที่แก้ end-to-end ด้วยขั้นตอนของคุณเอง
ข้อคิดสำคัญ
มี rollback สองแบบที่มีความเสี่ยงตรงกันข้าม: ของโค้ดราคาถูกและย้อนกลับได้ (เริ่มจากตรงนั้นเสมอ), ของข้อมูลทำลายล้างและย้อนกลับไม่ได้ เมื่อลังเล migration แบบ forward ที่ซ่อมแถวที่เสียหายเกือบทุกครั้งดีกว่า `down` ที่ลบมัน — เพราะบ่อยครั้งข้อมูลแค่เสียหาย ไม่ได้สูญหาย
ข้อผิดพลาดที่ควรเลี่ยง
- ×กระโดดตรงไปที่ 'แก้ไข' ด้วยการแตะ production แบบมืดบอดแทนที่จะทำตาม Detect→Contain→Recover: การด้นสดตอนตีสามคือวิธีที่ความตกใจกลายเป็นหายนะ
- ×ทำ rollback ของข้อมูล (down migration ด้วย DROP) โดยไม่มี backup/PITR ที่ยืนยันแล้ว เป็นการทำลายข้อมูลที่ bug แค่ทำให้เสียหาย
- ×ไม่กำหนด timeline ก่อนลงมือ: หากไม่รู้ว่า deploy หรือ migration ไหนเป็นตัวจุดชนวน incident คุณจะค้นหาสาเหตุในทั้งระบบแทนที่จะค้นในการเปลี่ยนแปลงล่าสุด
- ×สับสนระหว่างการควบคุมกับการแก้ไข: ปล่อยให้ bug เขียนแถวผิด ๆ ในขณะที่ 'กำลังสืบสวน' จะทวีคูณงาน cleanup ภายหลัง
- ×ปิด incident ด้วย post-mortem ที่หาคนผิดแทนที่จะหาความล้มเหลวของระบบ และที่ไม่สร้างการควบคุมใหม่แม้แต่อย่างเดียวที่จะป้องกันมันได้
OS-05 การแยกขอบเขตระหว่างโปรเจกต์
บทเรียน ทำให้หลายโปรเจกต์ใช้ฐานข้อมูล Postgres เดียวร่วมกันโดยที่โปรเจกต์หนึ่งอ่าน เขียน หรือทำลายข้อมูลของอีกโปรเจกต์ไม่ได้ — การแยก (isolation) แบบ multi-project และ multi-tenant ที่บังคับโดยฐานข้อมูล ไม่ใช่โดยข้อตกลง
การแยกขอบเขตระหว่างโปรเจกต์
บทเรียนทำให้หลายโปรเจกต์ใช้ฐานข้อมูล Postgres เดียวร่วมกันโดยที่โปรเจกต์หนึ่งอ่าน เขียน หรือทำลายข้อมูลของอีกโปรเจกต์ไม่ได้ — การแยก (isolation) แบบ multi-project และ multi-tenant ที่บังคับโดยฐานข้อมูล ไม่ใช่โดยข้อตกลง
การใช้ instance Supabase ร่วมกันระหว่างโปรเจกต์ช่วยประหยัดต้นทุนแต่สร้างความเสี่ยงที่เงียบที่สุดในกาแล็กซี: client ของ MOONKEY ที่มี anon key อ่านตารางของ XHUB IRON ได้ การแยกแบบ 'อย่าลืมว่าอย่าแตะตารางพวกนั้น' คือกระดาษ; วันหนึ่งจะมีคนแตะ การแยกที่แท้จริงเป็นเชิงโครงสร้างและตรวจสอบได้ด้วยการโจมตีมัน
บทเรียน
เริ่มจากข้อเท็จจริงที่เป็นรูปธรรม: instance `wuchsslgbqlhyxljsmxi` รองรับผลิตภัณฑ์ สองตัว MOONKEY ใช้ `profiles, progress, feedback, leads, proofs` ส่วน XHUB IRON ใช้ `iron_*, world_*, focus_*, daily_focus_history` ทั้งคู่ใช้ Postgres เดียวกัน, `auth.users` เดียวกัน, และ — สิ่งสำคัญ — anon key เดียวกัน ในการคุยกับฐานข้อมูล นี่หมายความว่า client สาธารณะของ MOONKEY ในทางเทคนิค สามารถ ลอง `from('iron_command').select('*')` ได้ เหตุผลเดียวที่มันไม่ทำงานต้องเป็นกำแพงแข็งในฐานข้อมูล ไม่ใช่บันทึกใน CLAUDE.md ที่บอกว่า 'อย่าแตะมัน' บันทึกนั้นปกป้องไม่ให้ คุณ แก้ไขมันโดยบังเอิญตอนเขียนโค้ด; มันไม่ได้ปกป้องจากผู้โจมตีที่มี anon key ของคุณอยู่แล้ว
จงเข้าใจชั้นของการแยกตั้งแต่อ่อนแอที่สุดไปแข็งแกร่งที่สุด (1) ข้อตกลง (ชื่อ `iron_*` vs ไม่มี prefix): จัดระเบียบ ไม่ได้แยก — ผู้โจมตีเพิกเฉยต่อข้อตกลง (2) RLS ต่อตาราง: แต่ละตารางของ XHUB ต้องมี policy ที่ให้สิทธิ์เข้าถึงเฉพาะ role/user ของตัวเอง; ตาราง `iron_*` ที่ไม่มี RLS หรือมี policy ที่หละหลวมคือการรั่วโดยตรง (3) schema แยกต่างหาก: วาง XHUB ไว้ใน schema Postgres ของตัวเองและ ไม่ เปิดเผยมันใน API ของ PostgREST (ใน `db.schema` ที่ Supabase publish) คือกำแพงที่แข็งแกร่งกว่า — สิ่งที่ไม่อยู่ใน schema ที่เปิดเผย client มองไม่เห็นแม้แต่นิด (4) Roles/grants: REVOKE จาก `anon`/`authenticated` บนตาราง/object ของผู้อื่น การป้องกันที่แข็งแกร่ง ซ้อน ชั้นเหล่านี้; อย่าวางใจชั้นเดียว
สำหรับกรณี MOONKEY↔XHUB คำถามของการ audit ตรงไปตรงมา: ผู้ใช้ MOONKEY ที่ authenticate แล้วอ่านแถวของ `iron_*` ได้หรือไม่? จงหักล้างมัน (OS-03): authenticate ในฐานะผู้ใช้ปกติของ MOONKEY แล้วรัน `supabase.from('iron_command').select('*')` สิ่งที่ถูกต้องคือมันต้องคืน error ของ permission หรือคืนค่าว่างเพราะ RLS หากมันคืนข้อมูลมา คุณมีการรั่วของ isolation ที่วิกฤต memory ของโปรเจกต์ระบุว่า health ถูก compute ขึ้นและโมเดลคือ 'open RLS, computed health' สำหรับ HUB — นั่นหมายความว่าคุณต้องตรวจสอบ อย่างชัดเจน ว่า 'open' สำหรับ HUB ไม่ได้แปลว่า 'เปิดให้ client ของ MOONKEY' การแยกระหว่างโปรเจกต์ = แต่ละตารางตอบสนองเฉพาะเจ้าของที่ชอบธรรมของมัน พิสูจน์ด้วยการ impersonation ข้ามโปรเจกต์
แกนที่สองคือ multi-tenant ภายใน โปรเจกต์เดียว ซึ่งคือ Espejo ตรงนี้ไม่ใช่สองผลิตภัณฑ์ แต่เป็น N tenant (influencer แต่ละราย) ในตารางเดียวกัน การแยกกลายเป็นแบบ ต่อแถว: แต่ละแถวมี `tenant_id` และ policy RLS บังคับให้ `tenant_id` ตรงกับ tenant ของผู้เรียก การโจมตีที่ต้องหักล้าง: tenant A พยายามอ่าน/เขียนแถวที่มี `tenant_id` ของ B กับดักคลาสสิกคือ UPDATE ที่ไม่มี WITH CHECK: USING จำกัดว่า A เห็นแถวไหน แต่หากไม่มี WITH CHECK A อาจ reassign แถวให้ B หรือสร้างแถวที่มี `tenant_id` ของคนอื่นได้ กับดักอีกอย่าง: RPC แบบ SECURITY DEFINER ที่รับ `tenant_id` เป็น parameter และไม่ validate ว่ามันตรงกับ `auth.uid()` — รูโหว่ที่ข้ามทุก tenant การแยกแบบ multi-tenant จะเป็นจริงก็ต่อเมื่อ ทุก policy ของทุก operation (SELECT/INSERT/UPDATE/DELETE) ยึด tenant ไว้กับตัวตนของผู้เรียก ไม่ใช่กับ parameter ที่ client ควบคุม
มี vector ของการข้ามที่ไม่ใช่ SELECT ซึ่งคนมักลืม `auth.users` ใช้ร่วมกัน: ผู้ใช้คนเดียวกันคือคนเดียวกันใน MOONKEY และใน XHUB — ตรวจสอบให้แน่ใจว่าการมีบัญชีในตัวหนึ่งไม่ได้ให้ role ในอีกตัว (ใน MOONKEY `role` และ `founder_badge` ไม่สามารถเปลี่ยนแปลงได้สำหรับผู้ที่ไม่ใช่ admin ก็เพื่อให้การลงทะเบียนไม่ยกระดับสิทธิ์ข้ามผลิตภัณฑ์นั่นเอง) RPC และฟังก์ชัน SECURITY DEFINER เป็น object ระดับ global: ฟังก์ชันของ XHUB ที่ `authenticated` รันได้ ก็เรียกได้จาก client ของ MOONKEY — จำกัดมันตาม role หรือ validate context ภายในมัน Trigger และ sequence ที่ใช้ร่วมกัน, view ที่ JOIN ตารางของทั้งสองผลิตภัณฑ์, และ `get_advisors` ที่ชี้ RLS ที่ขาดหายในตารางใด ๆ ของโปรเจกต์: ทั้งหมดนั้นคือพื้นผิวของการข้าม การแยกไม่ใช่ policy เดียว แต่เป็นคุณสมบัติของทั้งระบบ
ปิดท้ายด้วยวินัยปฏิบัติการที่รักษาการแยกให้มีชีวิตอยู่ตลอดเวลา การเปลี่ยน schema ทุกครั้งเข้ามาเป็น migration ที่มี version (`apply_migration`) ไม่ใช่การแก้ไขด้วยมือใน panel — เพื่อให้สถานะความปลอดภัยสร้างใหม่ได้และ audit ได้ หลังทุก migration ที่แตะตารางหรือ policy ให้รัน `get_advisors` เพื่อล่า RLS ที่ถูกปิดหรือ policy หละหลวมที่ถูกใส่เข้ามาโดยไม่ตั้งใจ และรักษา test ของการแยกไว้เป็นส่วนหนึ่งของ runbook: script ที่ในฐานะผู้ใช้ MOONKEY พยายามอ่านทุก prefix ของผู้อื่น (`iron_*`, `world_*`) และทำให้ build ล้มเหลวหากตัวใดคืนแถวมา การแยกระหว่างโปรเจกต์ไม่ใช่ 'ตั้งค่าครั้งเดียว'; มันถูกตรวจสอบซ้ำในทุกการเปลี่ยนแปลง เพราะ migration ที่ไร้พิษภัยอาจเปิดประตูที่ปิดมาหลายเดือนได้
แบบฝึกหัด
บนฐานข้อมูลที่ใช้ร่วมกัน `wuchsslgbqlhyxljsmxi` (หรือ replica สำหรับทดสอบ): 1) ในไฟล์ `ISOLATION.md` ให้ลิสต์ตารางของแต่ละผลิตภัณฑ์ (MOONKEY vs XHUB) และชั้นการแยกที่ปกป้องแต่ละตัว (RLS / schema ที่ไม่เปิดเผย / REVOKE) 2) เขียน test การ impersonation ข้ามโปรเจกต์: ในฐานะผู้ใช้ปกติของ MOONKEY ลอง `select('*')` บน `iron_command` (หรือ `iron_*`/`world_*` ใด ๆ) และบันทึกผลลัพธ์ที่คาดหวัง (error/ว่าง) 3) สำหรับกรณี multi-tenant ของ Espejo ให้เขียน policy ทั้งสี่ (SELECT/INSERT/UPDATE/DELETE) ของตารางที่มี `tenant_id` โดยให้แน่ใจว่า UPDATE มี WITH CHECK และไม่มีอันใดวางใจ `tenant_id` ที่ client ส่งมา 4) บันทึกว่า `get_advisors` เข้ามาอยู่ในกิจวัตรหลัง migration ของคุณอย่างไร
สิ่งที่ส่งมอบ
`ISOLATION.md`: แผนที่ตาราง→ชั้นการแยก สำหรับผลิตภัณฑ์ทั้งสองของฐานข้อมูลที่ใช้ร่วมกัน, test ที่รันได้ของการ impersonation ข้ามโปรเจกต์ MOONKEY→XHUB พร้อมผลลัพธ์ที่คาดหวัง, และชุด policy RLS ต่อ tenant ทั้งสี่ของ Espejo ที่มี WITH CHECK ถูกต้องในทุก operation ของการเขียน
ข้อคิดสำคัญ
การใช้ Postgres ร่วมกันระหว่างโปรเจกต์ปลอดภัยก็ต่อเมื่อการแยกเป็นเชิงโครงสร้าง (RLS + schema ที่ไม่เปิดเผย + REVOKE) ไม่ใช่เชิงเอกสาร: บันทึก 'อย่าแตะตารางพวกนั้น' ปกป้องจากความผิดพลาดในการเขียนโค้ดของคุณ ไม่เคยปกป้องจากผู้โจมตีที่มี anon key ของคุณอยู่แล้วและกำลังไล่ enumerate prefix ของผู้อื่น
ข้อผิดพลาดที่ควรเลี่ยง
- ×ฝากการแยกไว้กับข้อตกลงในการตั้งชื่อหรือบันทึกใน CLAUDE.md: ผู้โจมตีที่มี anon key เพิกเฉยต่อทั้งสองและลอง `from('iron_*').select('*')` โดยตรง
- ×ปล่อยตารางของผู้อื่น (`iron_*`, `world_*`) ไว้โดยไม่มี RLS หรือมี policy หละหลวม ในฐานข้อมูลที่หลายโปรเจกต์ใช้ anon key ร่วมกัน — การรั่วโดยตรงระหว่างผลิตภัณฑ์
- ×ใน multi-tenant เขียน UPDATE/INSERT ด้วย USING แต่ไม่มี WITH CHECK ทำให้ tenant หนึ่ง reassign หรือสร้างแถวด้วย `tenant_id` ของอีก tenant ได้
- ×สร้าง RPC แบบ SECURITY DEFINER ที่วางใจ `tenant_id` (หรือ `user_id`) ที่ client ส่งมาเป็น parameter แทนที่จะอนุมานมันจาก `auth.uid()`
- ×ปฏิบัติต่อการแยกเหมือนเป็นการตั้งค่าครั้งเดียวและไม่รัน `get_advisors` ซ้ำหลังทุก migration: การเปลี่ยนแปลงที่ไร้พิษภัยอาจเปิดประตูที่ปิดมาหลายเดือนได้อีกครั้ง
OS-06 Observabilidad y coste
บทเรียน ติดตั้ง panel observability และงบประมาณพร้อม alert สำหรับระบบที่มีชีวิต (Astro บน Cloudflare Pages + Supabase): นิยามว่าจะเฝ้าดู log และ metric อะไร, อะไรจุดชนวน alert, และเพดานการใช้งานและการใช้จ่าย token อะไรที่จะเตือนคุณ ก่อน บิลเซอร์ไพรส์จะมาถึง
Observabilidad y coste
บทเรียนติดตั้ง panel observability และงบประมาณพร้อม alert สำหรับระบบที่มีชีวิต (Astro บน Cloudflare Pages + Supabase): นิยามว่าจะเฝ้าดู log และ metric อะไร, อะไรจุดชนวน alert, และเพดานการใช้งานและการใช้จ่าย token อะไรที่จะเตือนคุณ ก่อน บิลเซอร์ไพรส์จะมาถึง
การ launch ไม่ใช่จุดจบ: มันคือช่วงที่คุณเริ่มปฏิบัติการ ระบบใน production สร้างกระแสสามอย่างที่แทบไม่มีใครมองจนกว่ามันจะเจ็บ: log (เกิดอะไรขึ้น), error (อะไรพังโดยไม่มีใครบอกคุณ) และต้นทุน (พวกเขาคิดเงินคุณเท่าไรในการมีชีวิตอยู่ต่อ) MOONKEY LAB, XHUB, Espejo และ XCAP ใช้ infrastructure จริงร่วมกัน ด้วยแผน free/pro และโควตาจริงของ Supabase และ Cloudflare และการใช้ AI ทั้งหมดคิดเงินตาม token ผู้ปฏิบัติการที่ไม่ติดตามจะค้นพบปัญหาผ่านสองทาง ซึ่งทั้งคู่แพง: ผู้ใช้ที่ร้องเรียน หรือยอดที่ขึ้นในบัตร observability เปลี่ยน 'ฉันไม่รู้ว่าเกิดอะไรขึ้นใน production' ให้เป็น 'ฉันมีแดชบอร์ดที่บอกฉัน' ต้นทุนคือความปลอดภัย: endpoint ที่เปิดโล่งให้คนมาถล่มไม่ได้เป็นแค่รูโหว่ แต่เป็นบิล นี่คือวินัยสุดท้ายของ constellation เพราะมันปิดวงจร: คุณสร้าง คุณรักษาความปลอดภัย ตอนนี้คุณปฏิบัติการด้วยตาที่เปิดอยู่
บทเรียน
สามกระแสของระบบที่มีชีวิต Log = ไดอารีว่าเกิดอะไรขึ้น (request, query, auth) Error = สิ่งที่พังเงียบ ๆ ในฝั่ง client ที่ log ของ server ของคุณเข้าไม่ถึง Cost = ตัวนับที่เดินอยู่แม้ไม่มีใครใช้ระบบ มันคือสามท่อที่ต่างกันด้วยเครื่องมือสามอย่างที่ต่างกัน; การสับสนมันคือเหตุผลที่คนเชื่อว่าตัวเองกำลังติดตามอยู่ทั้งที่จริงไม่ได้
observability ในระบบ SSG ไม่เหมือนในระบบ server MOONKEY เป็น static: ไม่ มี handler ฝั่ง server ที่จะวาง logger telemetry ของคุณอยู่ในสามที่ที่ไม่ใช่ HTML ของคุณ: (1) Cloudflare Pages Analytics (request, bandwidth, error ของ edge), (2) log ของ Supabase (Dashboard → Logs: API, Postgres, Auth — ทุก query ที่ผ่าน PostgREST และทุก login ทิ้งร่องรอยไว้), และ (3) browser ของผู้ใช้ (error ของ JS ที่มีแค่คุณคนเดียวที่มองไม่เห็น) จงยอมรับมัน: ในระบบ SSG สิ่งที่พังส่วนใหญ่เกิดบนเครื่องที่คุณควบคุมไม่ได้
เฝ้าดูสิ่งที่สำคัญจริง ๆ ไม่ใช่ทุกอย่าง สิ่งล่อใจคือ log ทุกอย่างแล้วไม่อ่านอะไรเลย จงนิยามสัญญาณกำมือเดียวที่สำคัญ: อัตรา error 4xx/5xx ที่ edge (จุดพีคของ 401/403 = RLS ปฏิเสธ หรือผู้โจมตีกำลังลอง), error ของ Auth ใน Supabase (ความพยายาม magic-link ที่ล้มเหลว), latency ของ query ช้า (Supabase ทำเครื่องหมายอันที่ช้า), และปริมาณแถวที่อ่าน (query ที่ไม่มี filter ซึ่งจู่ ๆ คืน 10,000 แถวคือ bug ของต้นทุน) metric ที่ไม่จุดชนวนการตัดสินใจใดเลยคือ noise; ลบมันทิ้ง
error tracking ฝั่ง client: จุดบอด ในเว็บไซต์ static TypeError ใน JS ของคุณทำลายประสบการณ์ของผู้ใช้และไม่มีอะไรมาถึงคุณเลย — ไม่มี log ของ server ไม่มี alert คุณต้องดักจับ error ใน browser แล้วส่งมันออกไป ขั้นต่ำที่ซื่อสัตย์: window.addEventListener('error', ...) และ ('unhandledrejection', ...) ที่ fetch ไปยัง endpoint ของตัวเอง (Edge Function ของ Supabase ที่ insert ลงตาราง log ที่มี RLS แบบ insert-อย่างเดียว) แบบที่แข็งแกร่ง: Sentry หรือคล้ายกันพร้อม SDK ฟรีจนถึงปริมาณหนึ่ง กฎ: หากคุณไม่ดักจับ error ของ client คุณกำลังปฏิบัติการแบบมืดบอดในครึ่งหนึ่งของระบบ
ต้นทุนที่แท้จริงของการปฏิบัติการ แยกย่อย Supabase free tier มีเพดานที่เป็นรูปธรรม ซึ่งเมื่อข้ามไป จะถูกตัดหรือถูกคิดเงิน: แถวของ DB, bandwidth ของ egress, storage, Monthly Active Users ของ Auth, และจำนวนการ invoke Edge Functions Cloudflare Pages ใจกว้างแต่มีลิมิตของ builds/เดือนและ request ใน function และการใช้จ่ายที่ขยายตัวมากที่สุดและมองเห็นน้อยที่สุด: token ของ AI ทุกการเรียก API ของ Claude คิดเงินตาม token ของ input และ output ด้วยอัตราที่ต่างกัน; prompt ที่มี CLAUDE.md มหึมา หรือ loop ของ agent ที่ไม่มีเพดานอาจทวีคูณต้นทุนโดยที่ 'สิ่งที่แอปทำ' ไม่เปลี่ยนเลย ผู้ปฏิบัติการรู้จักบิลทั้งสามของตัวเอง: DB, edge และ token
ทำไมบิลเซอร์ไพรส์ถึงมา และจะไม่รับมันได้อย่างไร สามสาเหตุคลาสสิก: (1) endpoint หรือ Edge Function ที่ไม่มี rate-limit ซึ่งมีคนค้นพบและถล่ม — การใช้ = เงิน; (2) query ที่ไม่มี LIMIT และไม่มี index ที่ scan ทั้งตารางในทุกการโหลด; (3) loop ของ agent AI ที่ไม่มีเพดาน token และไม่มีเพดาน iteration (XCAP capital invariant มีอยู่ส่วนหนึ่งก็เพราะเรื่องนี้: loop ที่สะสมการเรียนรู้ ไม่ใช่ต้นทุน) การป้องกันไม่ใช่การเฝ้าดูบิลตอนสิ้นเดือน แต่คือการใส่เบรก ก่อน: งบประมาณพร้อม alert ที่ 50/80/100%, ลิมิตแข็งในที่ที่ platform ให้, และเพดานที่ชัดเจนของ max_tokens และของ iteration ในทุกการเรียก AI
alert เฉพาะสิ่งที่สำคัญ โดยไม่เกิดความล้าจาก alert alert ที่เด้งทุกชั่วโมงจะถูกเพิกเฉยภายในหนึ่งสัปดาห์ เกณฑ์: alert เฉพาะสิ่งที่ลงมือทำได้และเร่งด่วน ดี: 'การใช้จ่ายของเดือนเกิน 80% ของงบประมาณ', 'อัตรา 5xx เกิน 1%', 'error ของ client > X ใน 10 นาที' แย่: 'มี request เกิดขึ้น' ตั้งค่า budget alert ที่ Supabase, Cloudflare และ console ของ Anthropic ให้คุณอยู่แล้ว — มันฟรีและเป็นแนวป้องกันแรกของคุณ ทุก alert ต้องตอบ 'ฉันจะทำอะไรเมื่อมันเด้ง'; หากไม่มี action มันไม่ใช่ alert แต่เป็นความวิตกกังวล
ปิดวงจรด้วยการตอบสนองต่อ incident (OS-04) การสังเกตโดยไม่มีแผนตอบสนองก็แค่การมองดูไฟไหม้ panel และ alert ของโมดูลนี้คือ sensor; rollback และ incident response ของ OS-04 คือ actuator ระบบที่ปฏิบัติการได้ดีเชื่อมสองอย่างนี้: alert ปลุกคุณ, log บอกคุณว่าเกิดอะไรขึ้น, และแผนตอบสนองบอกคุณว่าต้องทำอะไร observability + ต้นทุน + การตอบสนอง = การปฏิบัติการจริง ไม่ใช่การภาวนา
แบบฝึกหัด
คุณปฏิบัติการ MOONKEY LAB (Astro SSG บน Cloudflare Pages + Supabase moonkey-lab + การเรียก AI ที่อาจมี) ติดตั้ง observability จริงและงบประมาณของมันในสี่ขั้นตอน ขั้นที่ 1 — แผนที่สัญญาณ (เป็นเอกสารส่งมอบ) เปิด Dashboard ของ Supabase → Logs แล้วตรวจดู stream ทั้งสาม (API, Postgres, Auth) บันทึกในตารางว่าคุณเฝ้าดูสัญญาณอะไรในแต่ละอัน (เช่น Auth: อัตรา magic-link ที่ล้มเหลว; API: ratio ของ 401/403; Postgres: query > 500ms) และของแต่ละอัน: threshold, การข้ามมันหมายความว่าอะไร, และมันจุดชนวน action อะไร อย่างน้อย 6 สัญญาณ อันที่ไม่มี action ให้ทิ้ง ขั้นที่ 2 — การดักจับ error ของ client implement script ระดับ global (ใน Layout.astro ข้าง ๆ IntersectionObserver ที่เป็น global อยู่แล้ว — อย่าทำซ้ำ) ด้วย window.addEventListener('error') และ ('unhandledrejection') ที่ fetch ไปยัง Edge Function ของ Supabase สร้าง Edge Function และตาราง client_errors ที่มี RLS แบบ insert อย่างเดียวสำหรับ anon (ห้าม SELECT สำหรับ anon เด็ดขาด: log ไม่อ่านจาก client) apply มันเป็น migration ที่มี version แล้วรัน get_advisors หลังจากนั้น (กฎต้นทุนของโปรเจกต์) จงจงใจทำให้เกิด error แล้วยืนยันว่าแถวปรากฏในตาราง ขั้นที่ 3 — บิลทั้งสามและเพดานของมัน บันทึกลิมิตปัจจุบันและที่ที่เห็นมัน: (a) Supabase — การใช้ DB, egress, Auth MAU, จำนวน invoke ของ function (Dashboard → Reports/Usage); (b) Cloudflare — builds และ request (Pages → Analytics); (c) token ของ AI — หากมีการเรียก API ของ Claude ให้คำนวณต้นทุนของการเรียกหนึ่งครั้งโดยทั่วไป (token input ของ CLAUDE.md + prompt, token output ที่คาดหวัง, คูณด้วยอัตราปัจจุบันของ model) สำหรับแต่ละบิล ให้บันทึกเพดานฟรีและคุณอยู่ที่ % เท่าไรในวันนี้ ขั้นที่ 4 — เบรกและ alert เปิดใช้ budget alert ใน console ของ Anthropic และใน Supabase (หรือบันทึกให้ชัดว่าตั้งค่าที่ไหนหากแผนของคุณไม่เปิดเผยมัน) สำหรับการเรียก AI ใด ๆ ในระบบ ให้กำหนด max_tokens อย่างชัดเจนและเพดาน iteration หากมันเป็น loop ส่งมอบ runbook หนึ่งหน้า: 'เมื่อ alert X เด้ง → ดู log Y → รัน action Z (ที่เชื่อมกับ rollback ของ OS-04)' อย่าส่งภาพ capture แบบ 'ดูเหมือนใช้งานได้' จงส่ง: ตารางสัญญาณ, Edge Function + migration ที่รันอยู่พร้อมแถว error จริงที่ดักจับได้, การแยกย่อยบิลทั้งสามพร้อมเปอร์เซ็นต์, และ runbook ที่เชื่อม alert → log → action
สิ่งที่ส่งมอบ
panel observability ที่ใช้งานได้จริงสำหรับ MOONKEY LAB ที่มี: (1) ตารางสัญญาณ 6+ อัน (threshold · ความหมาย · action) บน log จริงของ Supabase และ Cloudflare; (2) การดักจับ error ของ client ที่ใช้งานได้ — Edge Function + ตาราง client_errors ที่มี RLS แบบ insert อย่างเดียว apply เป็น migration ที่มี version และตรวจสอบแล้วด้วยแถว error จริง; (3) การแยกย่อยบิลทั้งสาม (Supabase, Cloudflare, token ของ AI) พร้อมเพดานฟรีและ % การใช้งานปัจจุบันของแต่ละอัน; และ (4) runbook หนึ่งหน้าพร้อม budget alert ที่เปิดใช้ ซึ่งเชื่อมแต่ละ alert กับ log และ action ตอบสนองของมัน (เชื่อมกับ rollback ของ OS-04)
ข้อคิดสำคัญ
ในระบบ SSG ไม่มี server ที่จะ log ดังนั้นครึ่งหนึ่งของสิ่งที่พังเกิดใน browser ของผู้ใช้และไม่เคยมาถึงคุณ: หากคุณไม่ดักจับ error ของ client คุณไม่ได้กำลังติดตาม แต่กำลังเดา และต้นทุนคือพื้นผิวการโจมตี ไม่ใช่แค่บรรทัดบัญชี — endpoint ที่ไม่มีเบรกซึ่งมีคนถล่มเป็นทั้งรูโหว่ความปลอดภัยและบิลเซอร์ไพรส์ในเวลาเดียวกัน ดังนั้นเพดาน (max_tokens, rate-limit, budget alert ที่ 80%) คือความปลอดภัย ไม่ใช่การบัญชี บิลไม่ได้เฝ้าดูตอนสิ้นเดือน; เบรกใส่ก่อน
ข้อผิดพลาดที่ควรเลี่ยง
- ×เชื่อว่า log = observe การสะสม log ที่ไม่มีใครอ่านไม่ใช่ observability; observability คือการมีสัญญาณกำมือเดียวที่จุดชนวนการตัดสินใจ metric ที่ไม่สั่งการอะไรเลยคือ noise: ลบมันทิ้ง
- ×เพิกเฉยต่อ error ของ client เพราะ 'server ไม่รายงานอะไร' ในระบบ SSG server ไม่ สามารถ รายงาน TypeError ที่ทำลายหน้าจอของผู้ใช้ได้ หากไม่ดักจับใน browser คุณปฏิบัติการแบบมืดบอดในครึ่งหนึ่งของแอป
- ×เปิดตาราง log ให้ SELECT สำหรับ anon log ถูก insert จาก client แต่ ไม่เคย ถูกอ่านจากมัน (คุณจะรั่วข้อมูลของผู้ใช้คนอื่น) RLS แบบ insert อย่างเดียวสำหรับ anon อ่านได้เฉพาะ admin — และผ่าน get_advisors
- ×launch loop ของ agent AI หรือ Edge Function โดยไม่มี max_tokens และไม่มีเพดาน iteration นี่คือสาเหตุอันดับ 1 ของบิลเซอร์ไพรส์: ต้นทุนขยายตัวอย่างมองไม่เห็นในขณะที่ 'สิ่งที่แอปทำ' ไม่เปลี่ยน
- ×เฝ้าดูบิลตอนสิ้นเดือนแทนที่จะใส่ budget alert ที่ 50/80/100% ก่อน เมื่อคุณเห็นยอด คุณจ่ายมันไปแล้ว; alert มีอยู่เพื่อเบรก ไม่ใช่เพื่อเสียดาย
- ×ความล้าจาก alert: ตั้งค่าแจ้งเตือนทุกอย่างจนทีมปิดเสียงมัน alert เฉพาะสิ่งที่ลงมือทำได้และเร่งด่วน; ทุก alert ต้องตอบ 'ฉันจะทำอะไรเมื่อมันเด้ง' มิฉะนั้นมันไม่ควรมีอยู่
- ×แตะตารางของโปรเจกต์อื่นในฐานข้อมูลที่ใช้ร่วมกัน (iron_*, world_*, focus_*) ตอนติดตั้ง logging ตาราง error เป็นของ MOONKEY; สร้างมันด้วย prefix ของตัวเองและอย่าเหยียบ XHUB IRON
กลุ่มดาวถัดไป
Operator Core
ปฏิบัติการ AI