Schema cơ sở dữ liệu tổ chức và nhóm cho B2B — dễ quản lý
Schema cơ sở dữ liệu tổ chức và nhóm cho B2B: mẫu quan hệ thực tế cho lời mời, trạng thái membership, kế thừa vai trò và ghi nhận thay đổi theo chuẩn audit.

Vấn đề mà mẫu schema này giải quyết
Hầu hết ứng dụng B2B thực ra không chỉ là “ứng dụng tài khoản người dùng”. Chúng là không gian làm việc chia sẻ, nơi mọi người thuộc về một tổ chức, chia thành các nhóm, và có quyền khác nhau tùy công việc. Sales, support, finance và admin cần quyền khác nhau, và quyền đó thay đổi theo thời gian.
Một mô hình quá đơn giản sẽ nhanh chóng vỡ. Nếu bạn chỉ giữ một bảng users với một cột role duy nhất, bạn không thể biểu diễn “cùng một người là Admin ở org này nhưng là Viewer ở org khác.” Bạn cũng không xử lý được các trường hợp phổ biến như nhà thầu chỉ được thấy một team, hoặc một nhân viên rời dự án nhưng vẫn thuộc công ty.
Lời mời (invites) thường là nguồn lỗi khác. Nếu một invitation chỉ là một hàng email, sẽ khó biết người đó đã “ở trong” org chưa, họ dự định vào team nào, và chuyện gì xảy ra nếu họ đăng ký bằng email khác. Những không nhất quán nhỏ ở đây thường trở thành vấn đề bảo mật.
Mẫu này hướng đến bốn mục tiêu:
- Bảo mật: quyền xuất phát từ membership rõ ràng, không phải giả định.
- Rõ ràng: org, team và role mỗi thứ có một nguồn dữ liệu duy nhất.
- Nhất quán: lời mời và membership tuân theo vòng đời có thể dự đoán được.
- Lịch sử: bạn có thể giải thích ai đã cấp quyền, thay đổi role, hoặc xóa ai.
Lời hứa là một mô hình quan hệ đơn nhất vẫn dễ hiểu khi tính năng mở rộng: nhiều org cho một user, nhiều team cho một org, kế thừa role dự đoán được, và các thay đổi thân thiện với audit. Đây là cấu trúc bạn có thể triển khai hôm nay và mở rộng sau này mà không cần viết lại cả hệ thống.
Thuật ngữ chính: orgs, teams, users và memberships
Nếu bạn muốn một schema vẫn dễ đọc sau sáu tháng, hãy bắt đầu bằng việc thống nhất một vài từ. Phần lớn nhầm lẫn đến từ việc trộn lẫn “ai đó là ai” với “họ có thể làm gì.”
Một Organization (org) là ranh giới tenant ở cấp cao nhất. Nó biểu thị khách hàng hoặc tài khoản doanh nghiệp sở hữu dữ liệu. Nếu hai người dùng thuộc các org khác nhau, theo mặc định họ không nên nhìn thấy dữ liệu của nhau. Quy tắc này ngăn rất nhiều truy cập chéo tai hại.
Một Team là nhóm nhỏ hơn bên trong một org. Teams mô phỏng các đơn vị làm việc thực tế: Sales, Support, Finance, hoặc “Dự án A.” Teams không thay thế ranh giới org; chúng tồn tại dưới org.
Một User là một định danh. Là thông tin đăng nhập và hồ sơ: email, tên, mật khẩu hoặc SSO ID, và có thể có cài đặt MFA. Một user có thể tồn tại mà chưa có quyền truy cập gì.
Một Membership là bản ghi quyền truy cập. Nó trả lời: “User này thuộc org này (và tùy chọn một team) với trạng thái và các vai trò nào.” Tách biệt định danh (User) khỏi quyền (Membership) giúp dễ mô hình hóa nhà thầu, offboarding và truy cập đa-org.
Ý nghĩa đơn giản để sử dụng trong code và UI:
- Member: người dùng có membership đang hoạt động trong org hoặc team.
- Role: một gói tên của các quyền (ví dụ Org Admin, Team Manager).
- Permission: một hành động được phép duy nhất (ví dụ “xem hóa đơn”).
- Tenant boundary: quy tắc rằng dữ liệu được phân vùng theo org.
Xem membership như một máy trạng thái nhỏ, không phải boolean. Các trạng thái thường gặp là invited, active, suspended và removed. Điều này giữ cho lời mời, phê duyệt và offboarding nhất quán và có thể kiểm toán.
Mô hình quan hệ đơn: bảng lõi và mối quan hệ
Một schema đa-tenant tốt bắt đầu với một ý tưởng: lưu “ai thuộc đâu” ở một chỗ, và giữ mọi thứ khác như bảng hỗ trợ. Bằng cách đó bạn có thể trả lời các câu cơ bản (ai ở trong org, ai ở trong team, họ có thể làm gì) mà không phải nhảy qua các mô hình rời rạc.
Các bảng lõi bạn thường cần:
- organizations: một hàng cho mỗi tài khoản khách hàng (tenant). Chứa tên, trạng thái, trường thanh toán và một id bất biến.
- teams: nhóm bên trong một organization (Support, Sales, Admin). Luôn thuộc về một organization.
- users: một hàng cho mỗi người. Đây là toàn cục, không theo org.
- memberships: cầu nối nói “user này thuộc org này” và tùy chọn “cũng thuộc team này.”
- role_grants (hoặc role_assignments): các role mà một membership có, ở mức org, team, hoặc cả hai.
Giữ khóa và ràng buộc chặt chẽ. Dùng primary key thay thế (UUID hoặc bigint) cho mỗi bảng. Thêm foreign key như teams.organization_id -> organizations.id và memberships.user_id -> users.id. Rồi thêm vài ràng buộc unique để ngăn trùng lặp trước khi chúng xuất hiện trong production.
Một vài quy tắc bắt lỗi dữ liệu xấu sớm:
- Một slug hoặc khoá ngoài cho org:
unique(organizations.slug) - Tên team trong org:
unique(teams.organization_id, teams.name) - Không trùng membership org:
unique(memberships.organization_id, memberships.user_id) - Không trùng membership team (nếu bạn mô hình membership team riêng):
unique(team_memberships.team_id, team_memberships.user_id)
Quyết định điều gì là append-only so với có thể cập nhật. Organizations, teams và users có thể cập nhật. Memberships thường cập nhật cho trạng thái hiện tại (active, suspended), nhưng các thay đổi cũng nên ghi vào một nhật ký truy cập append-only để thuận tiện cho audit sau này.
Lời mời và trạng thái membership giữ nhất quán
Cách dễ nhất để giữ truy cập sạch là coi một lời mời là một bản ghi riêng, không phải một membership nửa vời. Một membership có nghĩa là “user này hiện thuộc về.” Một invitation có nghĩa là “chúng tôi đã mời, nhưng quyền chưa thực sự có.” Tách chúng ra tránh thành viên ma, quyền nửa vời và câu hỏi “ai đã mời người này?” bí ẩn.
Mô hình trạng thái đơn giản, đáng tin cậy
Với memberships, dùng một tập trạng thái nhỏ bạn có thể giải thích cho bất cứ ai:
- active: user có thể truy cập org (và bất kỳ team nào họ thuộc)
- suspended: bị khóa tạm thời, nhưng lịch sử còn nguyên
- removed: không còn là thành viên, lưu lại cho audit và báo cáo
Nhiều đội tránh trạng thái membership là “invited” và thay vào đó giữ “invited” chỉ trong bảng invitations. Cách đó thường sạch hơn: hàng membership chỉ tồn tại cho user thực sự có quyền (active), hoặc đã từng có quyền (suspended/removed).
Mời bằng email khi chưa có account
Ứng dụng B2B thường gửi invite theo email khi chưa có tài khoản người dùng. Lưu email trên bản ghi invitation, cùng với nơi invite áp dụng (org hoặc team), role dự kiến, và người gửi. Nếu người đó sau này đăng ký bằng email đó, bạn có thể khớp với các invitation đang chờ và cho họ chấp nhận.
Khi invite được chấp nhận, xử lý trong một transaction: đánh dấu invitation là accepted, tạo membership, và ghi một mục audit (ai chấp nhận, khi nào, và email nào được dùng).
Định nghĩa các trạng thái kết thúc của invitation:
- expired: quá hạn và không thể chấp nhận
- revoked: bị hủy bởi admin và không thể chấp nhận
- accepted: đã chuyển thành membership
Ngăn trùng lặp invite bằng cách bắt “chỉ một invite đang chờ cho mỗi org hoặc team cho mỗi email.” Nếu bạn hỗ trợ re-invite, hoặc gia hạn expiry trên invite đang chờ, hoặc thu hồi invite cũ rồi phát hành token mới.
Roles và kế thừa mà không làm quyền rối rắm
Hầu hết ứng dụng B2B cần hai mức truy cập: những gì ai đó có thể làm ở cấp tổ chức, và những gì họ có thể làm trong một team cụ thể. Trộn chúng vào một cột role là nguyên nhân khiến ứng dụng trở nên không nhất quán.
Role cấp org trả lời câu hỏi như: người này có thể quản lý thanh toán, mời người khác, hoặc xem tất cả các team không? Role cấp team trả lời: họ có thể chỉnh sửa mục trong Team A, phê duyệt yêu cầu ở Team B, hay chỉ được xem?
Kế thừa role dễ quản lý khi tuân theo một quy tắc: một org role áp dụng ở mọi nơi trừ khi team có quy định rõ ràng khác. Điều này giữ hành vi dễ dự đoán và giảm dữ liệu trùng.
Một cách sạch để mô hình hóa là lưu gán role với phạm vi:
role_assignments:user_id,org_id, tùy chọnteam_id(NULL nghĩa là toàn org),role_id,created_at,created_by
Nếu bạn muốn “một role cho mỗi phạm vi”, thêm ràng buộc unique trên (user_id, org_id, team_id).
Rồi quyền hiệu dụng cho một team sẽ là:
-
Tìm gán cụ thể cho team (
team_id = X). Nếu có, dùng nó. -
Nếu không, fallback về gán toàn org (
team_id IS NULL).
Để mặc định theo nguyên tắc ít đặc quyền nhất, chọn một org role tối thiểu (thường là “Member”) và không cho nó quyền admin ẩn. Người mới không nên có truy cập team ngấm ngầm trừ khi sản phẩm của bạn thực sự cần. Nếu tự động cấp, hãy làm bằng cách tạo membership team rõ ràng, không phải âm thầm mở rộng org role.
Ghi đè nên hiếm và dễ thấy. Ví dụ: Maria là “Manager” của org (có thể mời, xem báo cáo), nhưng trong team Finance cô ấy chỉ là “Viewer.” Bạn lưu một gán org-wide cho Maria, cộng một gán scoped cho team Finance. Không copy quyền, và ngoại lệ thì hiển nhiên.
Tên role phù hợp cho các mẫu thông thường. Dùng permission rõ ràng chỉ khi bạn có các trường hợp cá biệt (như “có thể export nhưng không thể edit”), hoặc khi tuân thủ cần danh sách hành động rõ ràng. Dù thế nào, vẫn giữ ý tưởng phạm vi để mô hình tư duy nhất quán.
Thay đổi thân thiện với audit: theo dõi ai thay đổi quyền
Nếu app bạn chỉ lưu trạng thái hiện tại trên một hàng membership, bạn đánh mất câu chuyện. Khi ai đó hỏi “Ai đã cấp Alex quyền admin vào thứ Ba tuần trước?”, bạn không có câu trả lời tin cậy. Bạn cần lịch sử thay đổi, không chỉ trạng thái hiện tại.
Cách đơn giản nhất là một bảng audit riêng ghi các sự kiện truy cập. Xử lý nó như nhật ký append-only: không bao giờ sửa hàng audit cũ; chỉ thêm hàng mới.
Một bảng audit thực tế thường bao gồm:
actor_user_id(ai đã thực hiện thay đổi)subject_typevàsubject_id(membership, team, org)action(invite_sent, role_changed, membership_suspended, team_deleted)occurred_at(khi nó xảy ra)reason(chuỗi tự do tùy chọn như “contractor offboarding”)
Để nắm trước và sau, lưu một snapshot nhỏ các trường bạn quan tâm. Giữ nó giới hạn cho dữ liệu điều khiển truy cập, không phải hồ sơ người dùng đầy đủ. Ví dụ: before_role, after_role, before_state, after_state, before_team_id, after_team_id. Nếu bạn thích linh hoạt, dùng hai cột JSON (before, after), nhưng giữ payload nhỏ và nhất quán.
Với memberships và teams, soft delete thường tốt hơn hard delete. Thay vì xoá hàng, đánh dấu vô hiệu với các trường như deleted_at và deleted_by. Điều này giữ nguyên khoá ngoại và giúp giải thích quyền truy cập trong quá khứ. Hard delete vẫn có thể hợp lý cho các bản ghi thật sự tạm thời (như invites đã hết hạn), nhưng chỉ khi bạn chắc chắn không cần chúng sau này.
Với thiết lập này, bạn có thể trả lời các câu tuân thủ thường gặp nhanh chóng:
- Ai đã cấp hoặc thu hồi quyền, và khi nào?
- Thay đổi chính xác là gì (role, team, trạng thái)?
- Việc thu hồi quyền có phải phần của flow offboarding thông thường?
Từng bước: thiết kế schema trong cơ sở dữ liệu quan hệ
Bắt đầu đơn giản: một chỗ để nói ai thuộc gì và vì sao. Xây dựng từng bước nhỏ, và thêm quy tắc dần để dữ liệu không trôi vào trạng thái “gần đúng”.
Thứ tự thực tế phù hợp cho PostgreSQL và các cơ sở dữ liệu quan hệ khác:
-
Tạo
organizationsvàteams, mỗi bảng có primary key ổn định (UUID hoặc bigint). Thêmteams.organization_idlàm foreign key, và quyết định sớm xem tên team có bắt buộc duy nhất trong org hay không. -
Tách
userskhỏi membership. Đặt các trường định danh trongusers(email, status, created_at). Đặt “thuộc về org/team” trong bảngmembershipsvớiuser_id,organization_id, tùy chọnteam_id(nếu bạn mô hình như vậy), và một cộtstate(active, suspended, removed). -
Thêm
invitationsnhư một bảng riêng, không phải cột trên membership. Lưuorganization_id, tùy chọnteam_id,email,token,expires_at, vàaccepted_at. Thực thi uniqueness cho “một invite mở cho mỗi org + email + team” để tránh tạo trùng. -
Mô hình hoá roles với các bảng rõ ràng. Cách đơn giản là
roles(admin, member, v.v.) cộngrole_assignmentstrỏ tới phạm vi org (không cóteam_id) hoặc phạm vi team (team_idđược đặt). Giữ quy tắc kế thừa nhất quán và có thể kiểm thử. -
Thêm một trail audit từ ngày đầu. Dùng bảng
access_eventsvớiactor_user_id,target_user_id(hoặc email cho invites),action(invite_sent, role_changed, removed),scope(org/team), vàcreated_at.
Sau khi các bảng này tồn tại, chạy vài truy vấn admin cơ bản để kiểm chứng hiện thực: “ai có quyền org-wide?”, “team nào không có admin?”, và “invite nào đã hết hạn nhưng vẫn mở?” Những câu này thường hé lộ ràng buộc còn thiếu sớm.
Quy tắc và ràng buộc ngăn dữ liệu lộn xộn
Một schema giữ được sự lành mạnh khi cơ sở dữ liệu, không chỉ code, thực thi ranh giới tenant. Quy tắc đơn giản nhất: mọi bảng mang phạm vi tenant đều có org_id, và mọi lookup đều bao gồm nó. Ngay cả khi ai đó quên filter trong app, cơ sở dữ liệu nên chống lại kết nối chéo org.
Hàng rào giúp dữ liệu sạch
Bắt đầu với foreign key luôn trỏ “trong cùng org”. Ví dụ, nếu bạn lưu team membership riêng, một hàng team_memberships nên tham chiếu team_id và user_id, nhưng cũng mang org_id. Với khóa ghép, bạn có thể buộc rằng team được tham chiếu thuộc cùng org.
Các ràng buộc ngăn hầu hết vấn đề thường gặp:
- Một membership org active cho mỗi user: unique trên
(org_id, user_id)kèm điều kiện partial cho các hàng active (nếu DB hỗ trợ). - Một invite đang chờ cho mỗi email mỗi org hoặc team: unique trên
(org_id, team_id, email)khistate = 'pending'. - Token invite là duy nhất toàn cục và không tái sử dụng: unique trên
invite_token. - Team thuộc chính xác một org:
teams.org_idNOT NULL với foreign key tớiorgs(id). - Kết thúc membership thay vì xóa: lưu
ended_at(và tùy chọnended_by) để bảo vệ lịch sử audit.
Chỉ mục cho các lookup bạn thực sự làm
Index các truy vấn app chạy thường xuyên:
(org_id, user_id)cho “user này thuộc org nào?”(org_id, team_id)cho “liệt kê thành viên của team này”(invite_token)cho “chấp nhận invite”(org_id, state)cho “invitations đang chờ” và “members active”
Giữ tên org có thể đổi được. Dùng orgs.id bất biến ở mọi nơi, và coi orgs.name (và slug) như các trường có thể chỉnh sửa. Đổi tên chỉ chạm một hàng.
Di chuyển team giữa các org thường là quyết định về chính sách. An toàn nhất là cấm (hoặc nhân bản team) vì memberships, roles và lịch sử audit là theo org. Nếu bắt buộc cho phép, làm trong một transaction duy nhất và cập nhật tất cả hàng con mang org_id.
Để tránh bản ghi mồ côi khi user rời, tránh xóa cứng. Vô hiệu hóa user, kết thúc memberships, và hạn chế xóa trên các hàng cha (ON DELETE RESTRICT) trừ khi bạn thực sự muốn xoá theo chuỗi.
Ví dụ kịch bản: một org, hai team, thay đổi truy cập an toàn
Hãy tưởng tượng một công ty tên Northwind Co có một org và hai team: Sales và Support. Họ thuê một nhà thầu, Mia, để xử lý ticket Support trong một tháng. Mô hình ở đây nên vẫn dự đoán được: một người, một membership org, membership team tùy chọn, và trạng thái rõ ràng.
Một org admin (Ava) mời Mia bằng email. Hệ thống tạo một hàng invitation gắn với org, trạng thái pending và ngày hết hạn. Chưa có gì thay đổi, nên không có “user nửa vời” với quyền unclear.
Khi Mia chấp nhận, invitation được đánh dấu accepted, và một hàng membership org được tạo với trạng thái active. Ava gán role org cho Mia là member (không phải admin). Sau đó Ava thêm Mia vào team Support và gán role team như support_agent.
Bây giờ thêm một tình huống: Ben là nhân viên toàn thời gian với role org là admin, nhưng anh ấy không nên thấy dữ liệu Support. Bạn xử lý bằng ghi đè cấp team để hạ cấp role của anh ở Support trong khi vẫn giữ quyền admin ở cấp org cho các cài đặt org.
Một tuần sau, Mia vi phạm chính sách và bị suspended. Thay vì xóa các hàng, Ava đặt trạng thái membership org của Mia thành suspended. Các membership team có thể giữ nguyên nhưng không có hiệu lực vì membership org không active.
Lịch sử audit vẫn rõ vì mỗi thay đổi là một event:
- Ava mời Mia (ai, gì, khi nào)
- Mia chấp nhận lời mời
- Ava thêm Mia vào Support và gán
support_agent - Ava tạo ghi đè cho Ben ở Support
- Ava suspend Mia
Với mô hình này, UI có thể hiển thị tóm tắt truy cập rõ ràng: trạng thái org (active hoặc suspended), role org, danh sách team với role và các ghi đè, và một feed “Recent access changes” giải thích tại sao ai đó có thể hoặc không thể xem Sales hay Support.
Sai lầm phổ biến và bẫy cần tránh
Hầu hết lỗi truy cập đến từ mô hình “gần đúng”. Schema trông ổn lúc đầu, rồi các trường hợp cạnh xuất hiện: re-invites, di chuyển team, thay đổi role, offboarding.
Bẫy phổ biến là trộn invitations và memberships trong cùng một hàng. Nếu bạn lưu “invited” và “active” trên cùng một record mà không rõ nghĩa, bạn sẽ gặp câu hỏi không thể trả lời như “Người này có phải là member nếu họ chưa bao giờ chấp nhận?” Giữ invitations và memberships tách biệt, hoặc làm máy trạng thái rõ ràng và nhất quán.
Lỗi thường gặp khác là đặt một cột role trên bảng users và cho rằng xong việc. Roles hầu như luôn có phạm vi (org role, team role, project role). Một role toàn cục buộc bạn phải vá bằng đủ kiểu như “user là admin cho một khách hàng nhưng chỉ đọc cho khách hàng khác,” điều này phá vỡ kỳ vọng multi-tenant và gây phiền toái hỗ trợ.
Các bẫy thường gây hại sau này:
- Vô tình cho phép membership cross-org (team_id trỏ tới org A, membership trỏ tới org B).
- Xóa cứng memberships và mất dấu “ai có quyền tuần trước?”
- Thiếu ràng buộc uniqueness khiến user có được quyền trùng lặp qua các hàng giống nhau.
- Để kế thừa chồng lấp một cách im lặng (org admin + team member + override) khiến không ai giải thích được tại sao có quyền.
- Xử lý “invite accepted” như một event UI, không phải là sự thật trong DB.
Một ví dụ nhanh: một nhà thầu được mời vào org, gia nhập Team Sales, rồi bị xóa và mời lại một tháng sau. Nếu bạn ghi đè row cũ, mất lịch sử. Nếu cho phép trùng lặp, họ có thể có hai membership active. Trạng thái rõ ràng, role theo phạm vi, và ràng buộc đúng ngăn cả hai trường hợp.
Kiểm tra nhanh và bước tiếp theo để xây vào app của bạn
Trước khi code, rà soát nhanh mô hình và xem nó còn hợp lý trên giấy không. Một mô hình truy cập đa-tenant tốt nên khiến bạn thấy nhàm chán: cùng quy tắc áp dụng khắp nơi, và “trường hợp đặc biệt” hiếm xuất hiện.
Checklist nhanh để phát hiện thiếu sót thường gặp:
- Mỗi membership trỏ chính xác đến một user và một org, với ràng buộc unique để tránh trùng lặp.
- Trạng thái invitation, membership, removal rõ ràng (không suy ra bằng null), và chuyển trạng thái có giới hạn (ví dụ, không thể chấp nhận invite đã hết hạn).
- Roles được lưu ở một chỗ và quyền hiệu dụng được tính nhất quán (bao gồm cả quy tắc kế thừa nếu dùng).
- Xóa orgs/teams/users không xóa sạch lịch sử (dùng soft delete hoặc trường lưu trữ nơi cần audit).
- Mỗi thay đổi truy cập phát ra một event audit với actor, target, scope, timestamp và lý do/nguồn.
Thử thiết kế với các câu hỏi thực tế. Nếu bạn không trả lời được những câu này bằng một truy vấn và một quy tắc rõ ràng, có lẽ bạn cần một ràng buộc hoặc một trạng thái thêm:
- Chuyện gì xảy ra nếu một user bị mời hai lần, rồi email thay đổi?
- Admin team có thể xóa quyền của owner org khỏi team đó không?
- Nếu một org role cấp quyền tới tất cả team, một team có thể ghi đè được không?
- Nếu invite được chấp nhận sau khi role đã thay đổi, role nào áp dụng?
- Khi support hỏi “ai đã xóa quyền,” bạn có thể chứng minh nhanh không?
Ghi ra những gì admin và support cần hiểu: trạng thái membership (và điều gì kích hoạt chúng), ai có thể mời/xóa, kế thừa role nghĩa là gì bằng ngôn ngữ dễ hiểu, và nơi tìm event audit khi có sự cố.
Thực hiện ràng buộc trước (uniques, foreign keys, transitions cho phép), sau đó xây logic nghiệp vụ quanh chúng để DB giúp bạn giữ đúng. Giữ các quyết định chính sách (kế thừa bật/tắt, role mặc định, expiry invite) trong bảng cấu hình thay vì constant trong code.
Nếu bạn muốn xây mà không viết tay mọi backend và màn hình admin, AppMaster (appmaster.io) có thể giúp bạn mô hình các bảng này trong PostgreSQL và triển khai các chuyển trạng thái invite và membership như các quy trình nghiệp vụ rõ ràng, đồng thời vẫn sinh mã nguồn thực cho triển khai production.
Câu hỏi thường gặp
Sử dụng một bản ghi membership riêng để vai trò và quyền truy cập gắn với một org (và tùy chọn một team), chứ không gắn trực tiếp vào danh tính người dùng toàn cục. Cách này cho phép cùng một người có thể là Admin ở một org và Viewer ở org khác mà không phải dựng các thủ thuật.
Giữ riêng: một invitation là một đề nghị chứa email, phạm vi và thời hạn, còn một membership biểu thị người dùng thực sự có quyền truy cập. Điều này tránh tạo ra “thành viên ma”, trạng thái mơ hồ và các lỗi bảo mật khi email thay đổi.
Một tập nhỏ như active, suspended, và removed là đủ cho hầu hết ứng dụng B2B. Nếu bạn chỉ giữ trạng thái “invited” trong bảng invitations, thì các hàng membership luôn rõ ràng: chúng biểu thị quyền truy cập hiện tại hoặc trong quá khứ, không phải quyền chờ xử lý.
Lưu các vai trò ở dạng gán với phạm vi: org-wide khi team_id là null, team-specific khi team_id được đặt. Khi kiểm tra quyền cho một team, ưu tiên gán cấp team nếu có; nếu không, dùng gán cấp org.
Bắt đầu với một quy tắc dễ hiểu: các org role áp dụng mặc định cho mọi nơi, và chỉ khi có gán riêng cho team thì mới ghi đè. Giữ các ghi đè hiếm và dễ thấy để mọi người có thể giải thích quyền truy cập mà không phải đoán.
Áp đặt “chỉ một invite đang chờ cho mỗi org/team cho mỗi email” bằng ràng buộc unique và dùng vòng đời rõ ràng pending/accepted/revoked/expired. Nếu cần re-invite, cập nhật thời hạn của invite đang chờ hoặc thu hồi invite cũ trước khi phát hành token mới.
Mỗi hàng thuộc phạm vi tenant nên mang org_id, và ràng buộc/khóa ngoại của bạn phải ngăn việc trộn lẫn các org (ví dụ: một team được tham chiếu bởi membership phải thuộc cùng org). Điều này giảm rủi ro khi app quên bộ lọc ở tầng ứng dụng.
Giữ một nhật ký sự kiện truy cập dạng append-only ghi ai làm gì, với ai, khi nào và ở phạm vi nào (org hoặc team). Ghi lại các trường before/after quan trọng (role, state, team) để bạn có thể trả lời câu hỏi như “ai đã cấp quyền admin vào thứ Ba tuần trước?” một cách đáng tin cậy.
Tránh xóa cứng memberships và teams; đánh dấu là đã kết thúc/vô hiệu để lịch sử còn truy vấn được và khóa ngoại không bị phá vỡ. Với invites, bạn cũng có thể giữ lại (kể cả khi hết hạn) nếu muốn theo dõi đầy đủ bảo mật, nhưng ít nhất không nên tái sử dụng token.
Tập trung vào các đường truy vấn nóng: (org_id, user_id) cho kiểm tra membership org, (org_id, team_id) cho danh sách thành viên team, (invite_token) cho chấp nhận invite, và (org_id, state) cho các màn hình admin như “active members” hoặc “pending invites”. Index nên phản ánh truy vấn thực tế của bạn, không phải mọi cột.


