สคีมาฐานข้อมูลองค์กรและทีมสำหรับ B2B ที่ยังคงเข้าใจได้
สคีมาฐานข้อมูลองค์กรและทีมสำหรับ B2B: แบบแผนเชิงสัมพันธ์เชิงปฏิบัติที่จัดการการเชิญ สถานะสมาชิก การสืบทอดบทบาท และการเปลี่ยนแปลงที่ตรวจสอบได้

ปัญหาที่สคีมานี้แก้ไขได้
แอป B2B ส่วนใหญ่ไม่ใช่แอป "บัญชีผู้ใช้" แบบเดี่ยว แต่เป็นพื้นที่ทำงานที่แชร์กันซึ่งผู้คนเป็นสมาชิกขององค์กร แบ่งเป็นทีม และได้รับสิทธิแตกต่างกันตามหน้าที่ งานขาย ฝ่ายสนับสนุน ฝ่ายการเงิน และแอดมินต้องการสิทธิแตกต่างกัน และสิทธิเหล่านั้นเปลี่ยนแปลงได้ตามเวลา
โมเดลง่ายเกินไปจะพังเร็ว หากคุณเก็บตาราง users เดียวพร้อมคอลัมน์ role เดียว คุณจะไม่สามารถระบุได้ว่า "คนเดียวกันเป็น Admin ใน org หนึ่ง แต่เป็น Viewer ในอีก org" คุณยังจัดการกรณีทั่วไปอย่างผู้รับเหมา (contractor) ที่ควรเห็นแค่ทีมเดียว หรือพนักงานที่ออกจากโปรเจคแต่ยังอยู่ในบริษัทไม่ได้
การเชิญก็เป็นแหล่งบั๊กบ่อยเช่นกัน ถ้าการเชิญเป็นแค่แถวของอีเมล มันจะไม่ชัดเจนว่าคนคนนั้น "เข้าร่วม" องค์กรแล้วหรือยัง ควรเข้าร่วมทีมไหน และจะเกิดอะไรขึ้นถ้าเขาสมัครด้วยอีเมลต่างกัน ความไม่สอดคล้องเล็กๆ น้อยๆ ที่นี่มักกลายเป็นปัญหาด้านความปลอดภัย
แพตเทิร์นนี้มีเป้าหมายสี่ข้อ:
- ความปลอดภัย: สิทธิได้มาจาก membership ที่ชัดเจน ไม่ใช่สมมติฐาน
- ความชัดเจน: org, ทีม และบทบาท แต่ละอย่างมีแหล่งความจริงหนึ่งเดียว
- ความสอดคล้อง: การเชิญและ membership ตามวงจรสถานะที่คาดเดาได้
- ประวัติ: คุณสามารถอธิบายได้ว่าใครมอบสิทธิ เปลี่ยนบทบาท หรือเอาใครออก
สัญญาคือโมเดลเชิงสัมพันธ์เดียวที่ยังคงเข้าใจได้เมื่อฟีเจอร์โตขึ้น: หลาย org ต่อผู้ใช้ หนึ่งผู้ใช้เข้าได้หลายทีมต่อ org การสืบทอดบทบาทที่คาดเดาได้ และการเปลี่ยนแปลงที่เป็นมิตรกับการตรวจสอบ นี่คือโครงสร้างที่คุณสามารถเริ่มใช้งานวันนี้และขยายได้โดยไม่ต้องเขียนใหม่ทั้งหมด
คำสำคัญ: orgs, teams, users และ memberships
ถ้าคุณต้องการสคีมาที่ยังอ่านออกหลังจากหกเดือน ให้เริ่มจากการตกลงคำไม่กี่คำ ความสับสนส่วนใหญ่มาจากการผสมระหว่าง "ใครเป็นใคร" กับ "พวกเขาทำอะไรได้"
Organization (org) คือขอบเขต tenant ชั้นบน มันแทนลูกค้าหรือบัญชีธุรกิจที่เป็นเจ้าของข้อมูล หากสองผู้ใช้เป็นคนละ org พวกเขาไม่ควรเห็นข้อมูลกันโดยดีฟอลต์ กฎเดียวนี้ป้องกันการเข้าถึงข้าม tenant โดยไม่ได้ตั้งใจได้มาก
Team คือกลุ่มเล็กภายใน org ทีมจำลองหน่วยการทำงานจริง: Sales, Support, Finance หรือ "Project A" ทีมไม่แทนที่ขอบเขต org; พวกมันอยู่ภายใต้ org
User คือเอกลักษณ์ หมายถึงการเข้าสู่ระบบและโปรไฟล์: อีเมล ชื่อ รหัสผ่านหรือ SSO ID และอาจมีการตั้งค่า MFA ผู้ใช้สามารถมีตัวตนโดยยังไม่มีสิทธิใดๆ
Membership คือบันทึกการเข้าถึง มันตอบคำถามว่า: "ผู้ใช้นี้เป็นสมาชิกของ org นี้ (และถ้าต้องการก็เป็นสมาชิกทีมนี้) ด้วยสถานะและบทบาทใด" การแยกเอกลักษณ์ (User) ออกจากการเข้าถึง (Membership) ทำให้การจัดการผู้รับเหมา การออกจากระบบ และการเข้าถึงหลาย org ง่ายขึ้น
ความหมายที่ใช้ในโค้ดและ UI ได้ง่ายๆ:
- Member: ผู้ใช้ที่มี membership ที่ใช้งานใน org หรือทีม
- Role: ชุดชื่อของสิทธิ (เช่น Org Admin, Team Manager)
- Permission: การกระทำเดียวที่อนุญาต (เช่น “ดูใบแจ้งหนี้”)
- Tenant boundary: กฎว่าข้อมูลถูกขอบเขตให้เป็นของ org ใด org หนึ่ง
มอง membership เป็นเครื่องสถานะเล็กๆ ไม่ใช่ค่า boolean สถานะทั่วไปคือ invited, active, suspended, และ removed วิธีนี้ช่วยให้การเชิญ การอนุมัติ และการออกจากระบบสอดคล้องและตรวจสอบได้
โมเดลเชิงสัมพันธ์เดียว: ตารางหลักและความสัมพันธ์
สคีมามัลติเทนแนนท์ที่ดีเริ่มจากไอเดียเดียว: เก็บว่า "ใครอยู่ที่ไหน" ในที่เดียว และเก็บสิ่งอื่นเป็นตารางสนับสนุน เพื่อให้คุณตอบคำถามพื้นฐาน (ใครอยู่ใน org ใครอยู่ในทีม พวกเขาทำอะไรได้) โดยไม่ต้องกระโดดผ่านโมเดลที่ไม่เกี่ยวกัน
ตารางหลักที่มักต้องมี:
- organizations: แถวหนึ่งต่อบัญชีลูกค้า (tenant) เก็บชื่อ สถานะ ช่องทางเรียกเก็บเงิน และ id คงที่
- teams: กลุ่มภายในองค์กร (Support, Sales, Admin) เสมอที่ต้องเป็นของ org เดียว
- users: แถวหนึ่งต่อคน นี่เป็นระดับโกลบอล ไม่ใช่ต่อองค์กร
- memberships: สะพานที่บอกว่า "ผู้ใช้นี้เป็นสมาชิกขององค์กรนี้" และถ้าต้องการระบุทีมด้วย
- role_grants (หรือ role_assignments): ระบุว่าการ membership ใดมีบทบาทอะไร ระดับ org ระดับทีม หรือทั้งสอง
เก็บคีย์และข้อจำกัดให้เข้มงวด ใช้ primary keys แบบ surrogate (UUID หรือ bigint) สำหรับแต่ละตาราง เพิ่ม foreign keys เช่น teams.organization_id -> organizations.id และ memberships.user_id -> users.id แล้วเพิ่ม unique constraints บางตัวเพื่อหยุดการซ้ำก่อนจะขึ้น production
กฎที่ช่วยจับข้อมูลไม่ถูกต้องส่วนใหญ่ตั้งแต่ต้น:
- slug หรือ external key ของ org ต้องไม่ซ้ำ:
unique(organizations.slug) - ชื่อทีมภายใน org:
unique(teams.organization_id, teams.name) - ไม่มี membership org ซ้ำ:
unique(memberships.organization_id, memberships.user_id) - ไม่มี membership ทีมซ้ำ (ถ้าคุณแยก team_memberships):
unique(team_memberships.team_id, team_memberships.user_id)
ตัดสินใจว่าข้อมูลใดเป็น append-only กับข้อมูลใดอัปเดตได้ Organizations, teams และ users สามารถอัปเดตได้ Memberships มักอัปเดตได้สำหรับสถานะปัจจุบัน (active, suspended) แต่การเปลี่ยนแปลงควรเขียนลงบันทึก append-only ด้วยเพื่อให้ง่ายต่อการตรวจสอบภายหลัง
การเชิญและสถานะสมาชิกที่คงเส้นคงวา
วิธีที่ง่ายที่สุดในการรักษาการเข้าถึงให้สะอาดคือมองว่าการเชิญเป็นเรคคอร์ดของตัวเอง ไม่ใช่ membership ที่ทำไม่เสร็จ Membership หมายถึง "ผู้ใช้คนนี้มีสิทธิแล้ว" ส่วนการเชิญหมายถึง "เราเสนอสิทธิ แต่ยังไม่จริง" การแยกกันแบบนี้ช่วยหลีกเลี่ยงสมาชิกผี สิทธิที่ถูกสร้างไม่ครบ และปริศนาว่าใครเชิญคนนี้
แบบสถานะง่ายๆ แต่เชื่อถือได้
สำหรับ memberships ใช้ชุดสถานะเล็กๆ ที่ใครก็อธิบายได้:
- active: ผู้ใช้สามารถเข้าถึง org (และทีมที่เป็นสมาชิก)
- suspended: ถูกบล็อกชั่วคราว แต่ประวัติยังอยู่
- removed: ไม่ใช่สมาชิกอีกต่อไป เก็บไว้เพื่อการตรวจสอบและรายงาน
หลายทีมหลีกเลี่ยงการเก็บสถานะ "invited" ในแถว membership และเก็บ "invited" ไว้เฉพาะในตาราง invitations วิธีนี้มักสะอาดกว่า: แถว membership มีอยู่เฉพาะสำหรับผู้ที่จริงๆ มีสิทธิ (active) หรือเคยมีสิทธิ (suspended/removed)
การเชิญทางอีเมลก่อนมีบัญชี
แอป B2B มักเชิญด้วยอีเมลเมื่อตอนที่ยังไม่มีบัญชีผู้ใช้ เก็บอีเมลบนแถว invitation พร้อมกับขอบเขตที่เชิญ (org หรือทีม) บทบาทที่ตั้งใจ และผู้ส่ง ถ้าคนนั้นมาสมัครภายหลังด้วยอีเมลเดียวกัน คุณก็สามารถจับคู่กับ invitations ที่คงค้างและให้พวกเขายอมรับได้
เมื่อการเชิญถูกยอมรับ ให้ทำงานเป็น transaction เดียว: ทำเครื่องหมาย invitation ว่า accepted, สร้าง membership, และเขียนบันทึก audit (ใครยอมรับ เมื่อไหร่ และใช้อีเมลใด)
กำหนดสถานะสิ้นสุดของ invite ให้ชัด:
- expired: เกินวันที่และไม่สามารถยอมรับได้
- revoked: ถูกยกเลิกโดยแอดมินและไม่สามารถยอมรับได้
- accepted: แปลงเป็น membership แล้ว
ป้องกันการเชิญซ้ำโดยบังคับ "เชิญค้างอยู่เพียงหนึ่งรายการต่อ org หรือทีม ต่ออีเมล" ถ้าคุณรองรับการเชิญซ้ำ ให้ต่ออายุ expiry ของ invite ที่คงค้างหรือเพิกถอนอันเก่าแล้วออกอันใหม่
บทบาทและการสืบทอดโดยไม่ทำให้การเข้าถึงซับซ้อน
แอป B2B ส่วนใหญ่ต้องการสองระดับการเข้าถึง: สิ่งที่ใครทำได้ในองค์กรโดยรวม และสิ่งที่ทำได้ภายในทีมเฉพาะ การผสมทั้งสองเข้าด้วยกันเป็นคอลัมน์ role เดียวเป็นจุดที่แอปเริ่มไม่สอดคล้อง
บทบาทระดับ org ตอบคำถามเช่น: คนนี้จัดการบิลได้ไหม เชิญคนอื่นได้ไหม หรือเห็นทุกทีมได้ไหม บทบาทระดับทีมตอบว่า: แก้ไขรายการในทีม A ได้ไหม อนุมัติคำขอในทีม B ได้ไหม หรือแค่ดูเท่านั้นไหม
การสืบทอดบทบาทง่ายที่สุดถ้าทำตามกฎข้อเดียว: บทบาทระดับ org ใช้ได้ทุกที่ เว้นแต่ทีมจะกำหนดอย่างชัดเจนว่าต้องใช้บทบาทอื่น กฎนี้ทำให้พฤติกรรมคาดเดาได้และลดข้อมูลซ้ำ
วิธีสะอาดคือเก็บการมอบหมายบทบาทพร้อมขอบเขต:
role_assignments:user_id,org_id,team_idที่เป็น option (NULL หมายถึง org-wide),role_id,created_at,created_by
ถ้าต้องการ "หนึ่งบทบาทต่อขอบเขต" ให้เพิ่ม unique constraint บน (user_id, org_id, team_id)
การเข้าถึงที่ใช้งานจริงสำหรับทีมจะเป็น:
-
มองหาการมอบหมายเฉพาะทีม (
team_id = X) ถ้ามี ให้ใช้ค่านั้น -
ถ้าไม่มี ให้ fallback ไปที่การมอบหมายระดับ org (
team_id IS NULL)
สำหรับค่าเริ่มต้นแบบ least-privilege ให้เลือกบทบาท org ขั้นต่ำ (มักเป็น “Member”) และอย่าให้มันมีพลัง admin แอบแฝง ผู้ใช้ใหม่ไม่ควรได้สิทธิทีมโดยอัตโนมัติ เว้นแต่ว่าผลิตภัณฑ์ของคุณจำเป็นจริง หากคุณให้สิทธิอัตโนมัติ ให้ทำโดยการสร้าง membership ทีมอย่างชัดเจน ไม่ใช่โดยการขยายบทบาท org เงียบๆ
การโอเวอร์ไรด์ควรหายากและชัดเจน ตัวอย่าง: มาเรียเป็น org “Manager” (เชิญคนได้ ดูรายงานได้) แต่ในทีมการเงินเธอควรเป็น “Viewer” คุณเก็บการมอบหมายระดับ org หนึ่งแถวสำหรับมาเรีย และเพิ่มการมอบหมายที่มีขอบเขตทีมสำหรับการเงิน เป็นข้อยกเว้นที่เห็นได้ชัด ไม่ต้องก็อปปี้สิทธิ และข้อยกเว้นนี้มองเห็นได้
ชื่อบทบาทเหมาะสำหรับรูปแบบทั่วไป ใช้ permissions แบบละเอียดเฉพาะเมื่อมีข้อยกเว้นจริงๆ (เช่น "ส่งออกได้แต่แก้ไขไม่ได้") หรือเมื่อ compliance ต้องการรายการการกระทำที่ชัดเจน แม้ในกรณีนั้น ให้เก็บแนวคิดขอบเขตเดียวกันเพื่อรักษาโมเดลทางความคิดให้สอดคล้อง
การเปลี่ยนแปลงที่เป็นมิตรกับการตรวจสอบ: บันทึกว่าใครเปลี่ยนการเข้าถึง
ถ้าแอปเก็บแค่บทบาทปัจจุบันบนแถว membership คุณจะสูญเสียเรื่องราว เมื่อมีคนถามว่า "ใครให้ Alex เป็น admin เมื่อวันอังคารที่ผ่านมา?" คุณจะไม่มีคำตอบที่เชื่อถือได้ คุณต้องมีประวัติการเปลี่ยนแปลง ไม่ใช่แค่สถานะปัจจุบัน
วิธีที่ง่ายที่สุดคือมีตาราง audit เฉพาะที่บันทึกเหตุการณ์การเข้าถึง ปฏิบัติกับมันเหมือนสมุดบันทึกแบบ append-only: อย่าแก้บรรทัด audit เก่า ให้เพิ่มแถวใหม่เสมอ
ตาราง audit ที่ใช้งานได้มักรวม:
actor_user_id(ใครทำการเปลี่ยนแปลง)subject_typeและsubject_id(membership, team, org)action(invite_sent, role_changed, membership_suspended, team_deleted)occurred_at(เมื่อเกิดขึ้น)reason(ข้อความอิสระเช่น “contractor offboarding”)
เพื่อจับ "ก่อน" และ "หลัง" ให้เก็บ snapshot เล็กๆ ของฟิลด์ที่คุณสนใจ จำกัดมันไว้ที่ข้อมูลควบคุมการเข้าถึง ไม่ใช่โปรไฟล์ผู้ใช้ทั้งหมด เช่น: before_role, after_role, before_state, after_state, before_team_id, after_team_id หากต้องการความยืดหยุ่น ใช้สองคอลัมน์ JSON (before, after) แต่เก็บ payload ให้เล็กและสม่ำเสมอ
สำหรับ memberships และ teams การ soft delete มักดีกว่าการลบจริง แทนที่จะลบแถว ให้ทำเครื่องหมายปิดด้วยฟิลด์อย่าง deleted_at และ deleted_by วิธีนี้รักษา foreign keys ไว้และทำให้ง่ายขึ้นในการอธิบายการเข้าถึงในอดีต การลบจริงอาจยังเหมาะสำหรับเรคคอร์ดชั่วคราวจริงๆ (เช่น invites ที่หมดอายุ) แต่เฉพาะเมื่อแน่ใจว่าจะไม่ต้องการพวกมันในภายหลัง
ด้วยสิ่งนี้ คุณสามารถตอบคำถามความสอดคล้องทั่วไปได้เร็วขึ้น:
- ใครให้หรือเอาสิทธิออก และเมื่อใด?
- เปลี่ยนแปลงอะไรบ้าง (บทบาท ทีม สถานะ)?
- การเอาสิทธิออกเป็นส่วนหนึ่งของขั้นตอน offboarding ปกติหรือไม่?
ขั้นตอนทีละขั้น: ออกแบบสคีมาในฐานข้อมูลเชิงสัมพันธ์
เริ่มจากง่ายๆ: ที่เดียวที่บอกว่าใครเป็นสมาชิกอะไร และทำไม สร้างมันทีละน้อย และเพิ่มกฎเมื่อไปเพื่อที่ข้อมูลจะไม่ล้มเป็น "เกือบถูกต้อง"
ลำดับปฏิบัติที่ใช้ได้จริงใน PostgreSQL และฐานข้อมูลเชิงสัมพันธ์อื่นๆ:
-
สร้าง
organizationsและteamsแต่ละตารางมี primary key เสถียร (UUID หรือ bigint) เพิ่มteams.organization_idเป็น foreign key และตัดสินใจตั้งแต่ต้นว่าชื่อทีมต้องไม่ซ้ำภายใน org หรือไม่ -
แยก
usersออกจาก membership ใส่ฟิลด์เอกลักษณ์ในusers(email, status, created_at) ใส่ "เป็นสมาชิก org/team" ในตารางmembershipsที่มีuser_id,organization_id,team_idที่เป็น option (ถ้าคุณโมเดลแบบนั้น) และคอลัมน์state(active, suspended, removed) -
เพิ่ม
invitationsเป็นตารางของตัวเอง ไม่ใช่คอลัมน์ใน membership เก็บorganization_id,team_idที่เป็น option,email,token,expires_at, และaccepted_atบังคับ uniqueness สำหรับ "เชิญค้างอยู่หนึ่งรายการต่อ org + อีเมล + ทีม" เพื่อไม่ให้สร้างซ้ำ -
โมเดลบทบาทด้วยตารางชัดเจน แนวทางง่ายๆ คือ
roles(admin, member ฯลฯ) และrole_assignmentsที่ชี้ไปยังขอบเขต org (ไม่มีteam_id) หรือขอบเขตทีม (team_idถูกตั้ง) รักษากฎการสืบทอดให้สอดคล้องและทดสอบได้ -
เพิ่ม audit trail ตั้งแต่วันแรก ใช้
access_eventsที่มีactor_user_id,target_user_id(หรืออีเมลสำหรับ invites),action(invite_sent, role_changed, removed),scope(org/team), และcreated_at
หลังจากตารางเหล่านี้พร้อม ให้รันคำสืบค้นแอดมินพื้นฐานสองสามคำสั่งเพื่อตรวจสอบความเป็นจริง: “ใครมีสิทธิระดับ org?” “ทีมใดไม่มีแอดมิน?” และ “คำเชิญไหนหมดอายุแต่ยังเปิดอยู่?” คำถามเหล่านี้มักจะเผยข้อจำกัดที่หายไปตั้งแต่ต้น
กฎและข้อจำกัดที่ป้องกันข้อมูลรก
สคีมาจะยังเป็นระเบียบเมื่อฐานข้อมูล ไม่ใช่แค่โค้ด ช่วยบังคับขอบเขต tenant ง่ายที่สุดคือ: ทุกตารางที่มีขอบเขต tenant ต้องมี org_id และทุกการค้นหาควรรวมมัน แม้ใครจะลืมเงื่อนไขกรองในแอป ฐานข้อมูลก็ควรต้านการเชื่อมต่อข้าม org
เกราะกันที่ช่วยรักษาความสะอาดของข้อมูล
เริ่มจาก foreign keys ที่ชี้ "ภายใน org เดียวกัน" ตัวอย่างเช่น ถ้าคุณเก็บ team_memberships แยกต่างหาก แถว team_memberships ควรอ้างอิง team_id และ user_id แต่ยังต้องมี org_id ด้วย ด้วยคีย์ประกอบ คุณสามารถบังคับให้ทีมที่อ้างอิงเป็นของ org เดียวกันได้
ข้อจำกัดที่ป้องกันปัญหาพบบ่อยที่สุด:
- หนึ่ง membership org ที่ active ต่อผู้ใช้ต่อ org: unique บน
(org_id, user_id)พร้อมเงื่อนไข partial สำหรับแถว active (ถ้าฐานข้อมูลรองรับ) - เชิญค้างอยู่หนึ่งรายการต่ออีเมลต่อ org หรือทีม: unique บน
(org_id, team_id, email)เมื่อstate = 'pending' - token การเชิญไม่ซ้ำทั่วระบบและไม่ถูกนำกลับใช้: unique บน
invite_token - ทีมต้องเป็นของ org เดียว:
teams.org_idNOT NULL พร้อม foreign key ไปยังorgs(id) - จบ membership แทนการลบ: เก็บ
ended_at(และอาจมีended_by) เพื่อปกป้องประวัติการตรวจสอบ
การทำดัชนีสำหรับการค้นหาที่ใช้จริง
ทำดัชนีสำหรับคำสืบค้นที่แอปของคุณเรียกบ่อย:
(org_id, user_id)สำหรับ "ผู้ใช้นี้อยู่ใน org ใดบ้าง?"(org_id, team_id)สำหรับ "รายการสมาชิกของทีมนี้"(invite_token)สำหรับ "ยอมรับ invite"(org_id, state)สำหรับ "invites ค้างอยู่" และ "สมาชิกที่ active"
เก็บชื่อ org ให้เปลี่ยนได้ ใช้ orgs.id ที่ไม่เปลี่ยนที่ทุกที่ และมอง orgs.name (หรือ slug) เป็นฟิลด์ที่แก้ไขได้ การเปลี่ยนชื่อจึงแก้เพียงแถวเดียว
การย้ายทีมข้าม org มักเป็นการตัดสินใจเชิงนโยบาย ตัวเลือกที่ปลอดภัยที่สุดคือห้าม (หรือโคลนทีม) เพราะ membership, บทบาท และประวัติ audit ถูกขอบเขตด้วย org หากจำเป็นต้องย้าย ให้ทำใน transaction เดียวและอัปเดตทุกแถวลูกที่มี org_id
เพื่อป้องกันเรคคอร์ดหลุดเมื่อผู้ใช้ลาออก หลีกเลี่ยงการลบจริง ปิดผู้ใช้ จบ memberships และจำกัดการลบของแถวหลัก (ON DELETE RESTRICT) เว้นแต่คุณต้องการ cascade จริงๆ
ตัวอย่างสถานการณ์: หนึ่ง org สองทีม การเปลี่ยนสิทธิอย่างปลอดภัย
จินตนาการบริษัทชื่อ Northwind Co มี org เดียวและสองทีม: Sales และ Support พวกเขาจ้างผู้รับเหมา Mia มาช่วยตั๋ว Support หนึ่งเดือน โมเดลควรคงความคาดเดาได้: คนหนึ่งคน, membership ระดับ org หนึ่งแถว, membership ทีมเป็นทางเลือก และสถานะชัดเจน
แอดมิน org (Ava) เชิญ Mia ทางอีเมล ระบบสร้างแถว invitation ผูกกับ org โดยสถานะ pending และมีวันหมดอายุ ยังไม่มีอะไรเปลี่ยนแปลง ดังนั้นจะไม่มี "ผู้ใช้ครึ่งตัว" ที่มีการเข้าถึงไม่ชัดเจน
เมื่อ Mia ยอมรับ invitation แถว invitation ถูกตั้งเป็น accepted และสร้างแถว membership ระดับ org ที่มีสถานะ active Ava ตั้งบทบาท org ของ Mia เป็น member (ไม่ใช่ admin) แล้ว Ava เพิ่ม membership ทีม Support และกำหนดบทบาททีมเป็น support_agent
เพิ่มความซับซ้อนเล็กน้อย: Ben เป็นพนักงานประจำที่มีบทบาท org เป็น admin แต่ไม่ควรเห็นข้อมูล Support คุณจัดการได้โดยการกำหนดการโอเวอร์ไรด์ระดับทีมที่ลดบทบาทของเขาใน Support ขณะที่ยังคงสิทธิ admin ระดับ org สำหรับการตั้งค่า org
สัปดาห์ต่อมา Mia ทำผิดนโยบายและถูกระงับ แทนที่จะลบแถว Ava เปลี่ยนสถานะ membership ระดับ org ของ Mia เป็น suspended memberships ทีมสามารถคงอยู่แต่จะไม่มีผลเพราะ membership ระดับ org ไม่ active
ประวัติ audit ยังคงสะอาดเพราะแต่ละการเปลี่ยนเป็นเหตุการณ์:
- Ava เชิญ Mia (ใคร อะไร เมื่อไหร่)
- Mia ยอมรับ invite
- Ava เพิ่ม Mia ใน Support และมอบ
support_agent - Ava ตั้งการโอเวอร์ไรด์สำหรับ Ben ใน Support
- Ava ระงับ Mia
ด้วยโมเดลนี้ UI จะแสดงสรุปการเข้าถึงที่ชัดเจน: สถานะ org (active หรือ suspended), บทบาท org, รายการทีมพร้อมบทบาทและโอเวอร์ไรด์ และฟีด "การเปลี่ยนแปลงการเข้าถึงล่าสุด" ที่อธิบายว่าทำไมใครบางคนถึงมองเห็นหรือไม่เห็น Sales หรือ Support
ความผิดพลาดและกับดักที่พบบ่อย
บั๊กการเข้าถึงส่วนใหญ่เกิดจากโมเดลข้อมูลที่ "เกือบถูก" สคีมาดูดีตอนแรก แล้ว edge cases กองพะเนิน: การเชิญซ้ำ การย้ายทีม การเปลี่ยนบทบาท และการ offboarding
กับดักทั่วไปคือผสมการเชิญกับ membership ในแถวเดียว ถ้าคุณเก็บ "invited" กับ "active" ในแถวเดียวโดยไม่ชัดเจน คุณจะถามคำถามเป็นไปไม่ได้อย่าง "คนนี้เป็นสมาชิกไหมถ้าเขาไม่เคยยอมรับ?" แยก invitations และ memberships หรือทำให้เครื่องสถานะชัดเจนและสม่ำเสมอ
ข้อผิดพลาดอีกอย่างคือใส่คอลัมน์ role เดียวบนตารางผู้ใช้และคิดว่าเสร็จแล้ว บทบาทมักมีขอบเขต (บทบาท org, บทบาททีม, บทบาทโปรเจค) บทบาทระดับโกลบอลบังคับให้เกิดทริกซ์ เช่น "ผู้ใช้เป็น admin สำหรับลูกค้าหนึ่ง แต่เป็น read-only สำหรับอีกคน" ซึ่งทำลายความคาดหวังมัลติเทนแนนท์และสร้างปัญหาฝ่ายสนับสนุน
กับดักที่มักทำให้เจ็บยาว:
- อนุญาต membership ข้าม org โดยไม่ได้ตั้งใจ (team_id ชี้ org A แต่ membership ชี้ org B)
- ลบ memberships แบบ hard delete แล้วสูญเสีย "ใครมีสิทธิเมื่อสัปดาห์ก่อน?"
- ขาดกฎ uniqueness ทำให้ผู้ใช้ได้สิทธิซ้ำจากแถวเดียวกัน
- ให้ inheritance สะสมเงียบๆ (org admin พลัส team member พลัส override) จนไม่มีใครอธิบายได้ว่าทำไมสิทธิจึงมี
- มองว่า "invite accepted" เป็นเหตุการณ์ UI ไม่ใช่ข้อเท็จจริงในฐานข้อมูล
ตัวอย่างด่วน: ผู้รับเหมาได้รับเชิญเข้า org แล้วเข้าร่วม Team Sales แล้วถูกเอาออกและเชิญใหม่อีกครั้งในเดือนถัดมา ถ้าคุณเขียนทับแถวเก่า คุณจะสูญเสียประวัติ ถ้าคุณอนุญาตซ้ำ พวกเขาอาจมีสอง membership ที่ active สถานะที่ชัดเจน ขอบเขตบทบาท และข้อจำกัดที่ถูกต้องป้องกันทั้งสองกรณี
การตรวจสอบอย่างรวดเร็วและขั้นตอนต่อไปเพื่อบรรจุเข้ากับแอปของคุณ
ก่อนเขียนโค้ด ให้ตรวจแบบโมเดลของคุณบนกระดาษและดูว่ายังสมเหตุสมผลไหม โมเดลการเข้าถึงมัลติเทนแนนท์ที่ดีควรรู้สึกน่าเบื่อ: กฎเดียวกันใช้ทุกที่ และ "กรณีพิเศษ" หายาก
เช็คลิสต์เร็วๆ เพื่อจับช่องว่างทั่วไป:
- ทุก membership ชี้ไปยังผู้ใช้คนเดียวและ org เดียว โดยมี unique constraint ป้องกันการซ้ำ
- สถานะ invitation, membership, และ removal ชัดเจน (ไม่อิง null) และทรานซิชันถูกจำกัด (เช่น คุณไม่สามารถยอมรับ invite ที่หมดอายุได้)
- บทบาทเก็บไว้ที่เดียวและการเข้าถึงที่มีผลคำนวณอย่างสม่ำเสมอ (รวมกฎการสืบทอดถ้ามี)
- การลบ orgs/teams/users ไม่ลบประวัติ (ใช้ soft delete หรือฟิลด์สำรองเมื่อจำเป็น)
- ทุกการเปลี่ยนแปลงการเข้าถึงปล่อยเหตุการณ์ audit ที่มี actor, target, ขอบเขต, timestamp และเหตุผล/แหล่งที่มา
ทดสอบการออกแบบด้วยคำถามจริง ถ้าคุณไม่สามารถตอบคำถามเหล่านี้ด้วยคำสืบค้นเดียวและกฎชัดเจน คุณอาจต้องข้อจำกัดหรือสถานะเพิ่ม:
- เกิดอะไรขึ้นถ้าผู้ใช้ถูกเชิญสองครั้ง แล้วอีเมลเปลี่ยน?
- แอดมินทีมสามารถเอา owner ของ org ออกจากทีมนั้นได้ไหม?
- ถ้าบทบาท org ให้สิทธิทุกทีม ทีมหนึ่งจะโอเวอร์ไรด์มันได้ไหม?
- ถ้า invite ถูกยอมรับหลังจากบทบาทถูกเปลี่ยน บทบาทไหนจะใช้?
- ฝ่ายสนับสนุนถามว่า "ใครเอาสิทธิออก" คุณพิสูจน์ได้เร็วไหม?
จดสิ่งที่แอดมินและฝ่ายสนับสนุนต้องเข้าใจ: สถานะ membership (และอะไรเป็นตัวกระตุ้น), ใครเชิญ/เอาออกได้, ความหมายของการสืบทอดบทบาทเป็นภาษาง่ายๆ, และที่ที่ควรมองหาเหตุการณ์ audit ระหว่างเหตุการณ์ความปลอดภัย
นำข้อจำกัดไปใช้ก่อน (uniques, foreign keys, allowed transitions) แล้วสร้างตรรกะทางธุรกิจรอบๆ เพื่อให้ฐานข้อมูลช่วยควบคุมความถูกต้อง เก็บการตัดสินใจเชิงนโยบาย (inheritance เปิด/ปิด, บทบาทเริ่มต้น, วันหมดอายุ invite) ในตารางคอนฟิกแทนการฝังเป็นค่าคงที่ในโค้ด
หากคุณต้องการสร้างสิ่งนี้โดยไม่เขียน backend และหน้าแอดมินทุกชิ้นด้วยมือ AppMaster (appmaster.io) สามารถช่วยให้คุณแบบจำลองตารางเหล่านี้ใน PostgreSQL และสร้างการเปลี่ยนแปลง invite และ membership เป็นกระบวนการธุรกิจที่ชัดเจน ในขณะที่ยังสร้างโค้ดต้นฉบับสำหรับการ deploy จริงได้
คำถามที่พบบ่อย
ใช้บันทึก membership แยกต่างหากเพื่อให้บทบาทและการเข้าถึงผูกกับ org (และถ้าต้องการก็ผูกกับทีม) ไม่ใช่กับไอดีผู้ใช้ระดับโลก วิธีนี้ทำให้บุคคลเดียวกันเป็น Admin ใน org หนึ่งและเป็น Viewer ในอีก org ได้โดยไม่ต้องใช้ทางลัดหรือทริกซ์
แยกไว้: invitation เป็นข้อเสนอที่มีอีเมล ขอบเขต และวันหมดอายุ ส่วน membership หมายความว่าผู้ใช้มีสิทธิจริงแล้ว วิธีนี้หลีกเลี่ยง “สมาชิกผี” สถานะไม่ชัด และช่องโหว่ความปลอดภัยเมื่ออีเมลเปลี่ยน
ชุดสถานะเล็กๆ เช่น active, suspended, และ removed เพียงพอสำหรับแอป B2B ส่วนใหญ่ หากเก็บสถานะ “invited” ไว้เฉพาะในตาราง invitations จะทำให้ memberships ชัดเจน: บรรทัด membership แสดงการเข้าถึงปัจจุบันหรือที่ผ่านมา ไม่ใช่การเข้าถึงที่ค้างอยู่
เก็บบทบาทระดับ org และบทบาทระดับทีมเป็นการมอบหมายที่มีขอบเขต (org-wide เมื่อ team_id เป็น null, ทีมเมื่อ team_id ถูกกำหนด) เมื่อเช็กการเข้าถึงสำหรับทีม ให้เลือกการมอบหมายที่ระบุทีมก่อน ถ้าไม่มีจึงค่อยสำรวจการมอบหมายระดับ org
เริ่มจากกฎที่คาดเดาได้หนึ่งข้อ: บทบาทระดับ org ใช้ได้ทั่วทั้งระบบตามค่าเริ่มต้น และบทบาทระดับทีมจะโอเวอร์ไรด์เฉพาะเมื่อกำหนดอย่างชัดเจน เก็บการโอเวอร์ไรด์ให้หายากและมองเห็นได้ เพื่อที่คนจะอธิบายการเข้าถึงได้โดยไม่ต้องเดา
บังคับ “เชิญหนึ่งรายการที่ค้างอยู่ต่อ org/ทีม ต่ออีเมล” โดยใช้ข้อจำกัด unique และวงจรสถานะชัดเจน pending/accepted/revoked/expired หากต้องการ re-invite ให้ปรับวันหมดอายุของ invite ที่มีอยู่หรือเพิกถอนแล้วออก token ใหม่
แถวที่มีขอบเขตต่อ tenant ทุกแถวควรมี org_id และคีย์ต่างๆ/ข้อจำกัดควรป้องกันการผสมข้าม org (เช่น ทีมที่ถูกอ้างอิงโดย membership ต้องเป็นของ org เดียวกัน) วิธีนี้ลดความเสี่ยงเมื่อโค้ดลืมเงื่อนไขกรองในแอป
เก็บบันทึกเหตุการณ์การเข้าถึงแบบ append-only ที่บันทึกว่าใครทำอะไรกับใคร เมื่อใด และในขอบเขตไหน (org หรือทีม) บันทึกฟิลด์สำคัญก่อน/หลัง (role, state, team) เพื่อให้สามารถตอบคำถามเช่น “ใครให้สิทธิ admin เมื่อวันอังคารที่แล้ว?” ได้อย่างน่าเชื่อถือ
หลีกเลี่ยงการลบจริงสำหรับ memberships และ teams; ให้ทำเป็นการปิดหรือยุติ (ended/disabled) เพื่อเก็บประวัติและรักษาความสมบูรณ์ของ foreign key สำหรับ invites คุณสามารถเก็บไว้ (แม้จะหมดอายุ) หากต้องการร่องรอยด้านความปลอดภัยอย่างเต็มรูปแบบ แต่อย่างน้อยอย่าใช้งาน token ซ้ำ
จัดทำดัชนีเส้นทางร้อนของคุณ: (org_id, user_id) สำหรับการตรวจสอบสมาชิก org, (org_id, team_id) สำหรับรายการสมาชิกทีม, (invite_token) สำหรับการยอมรับคำเชิญ, และ (org_id, state) สำหรับหน้าจอแอดมิน เช่น "สมาชิกที่ active" หรือ "invites ที่ค้างอยู่" ดัชนีควรสะท้อนการค้นหาจริงของแอป


