B2B組織とチームのデータベーススキーマ — 破綻しない設計
B2B向け組織・チームのデータベーススキーマ:招待、メンバーシップ状態、ロール継承、監査対応の変更を扱う実用的なリレーショナルパターン。

このスキーマパターンが解決する問題
多くのB2Bアプリは単なる「ユーザーアカウント」アプリではありません。人々が組織に属し、チームに分かれ、役割によって異なる権限を持つ共有ワークスペースです。営業、サポート、経理、管理者はそれぞれ異なるアクセスが必要で、時間とともにそのアクセスは変わります。
単純すぎるモデルはすぐに破綻します。1つの users テーブルに単一の role カラムだけを置くと、「同じ人物がある組織ではAdminで、別の組織ではViewer」という表現ができません。特定のチームのみを閲覧できる請負業者や、プロジェクトを離れた社員が会社にはまだ属しているといった一般的なケースも扱えません。
招待(Invites)もバグの温床です。招待を単なるメール行で扱うと、その人物が既に組織に「入っている」のか、どのチームに参加すべきか、違うメールでサインアップしたらどうなるかが不明瞭になります。ここでの小さな不整合がセキュリティ問題に発展することがよくあります。
このパターンは4つの目標を掲げます:
- セキュリティ:権限は暗黙の前提ではなく、明示的なメンバーシップから導出される。
- 明快さ:org、team、roleそれぞれに単一の信頼できる情報源がある。
- 一貫性:招待とメンバーシップは予測可能なライフサイクルに従う。
- 履歴:誰がいつアクセスを付与・変更・削除したかを説明できる。
約束は、機能が増えても理解可能な単一のリレーショナルモデルです:ユーザーごとの複数org、orgごとの複数チーム、予測可能なロール継承、監査に適した変更履歴。今日実装しても後で書き直しが要らない構造を目指します。
キー用語:org、team、user、membership
6か月後にも読みやすいスキーマにしたければ、まず用語を揃えましょう。多くの混乱は「その人が誰か(identity)」と「何ができるか(access)」を混同することから生じます。
Organization(org) はテナントの境界です。顧客やビジネスアカウントを表し、データを所有します。異なるorgにいるユーザー同士は、デフォルトでお互いのデータを見られないべきです。このルール一つで偶発的なクロステナントアクセスを大幅に防げます。
Team はorg内の小さなグループで、Sales、Support、Finance、あるいは「Project A」など実際の作業単位をモデル化します。Teamはorgの下位にあり、org境界を置き換えるものではありません。
User はアイデンティティです。ログインとプロフィール(メール、名前、パスワードやSSO ID、MFA設定など)を持ちます。ユーザーはまだ何にもアクセスしていなくても存在できます。
Membership はアクセスの記録です。次の問いに答えます:「このユーザーはこのorg(必要に応じてこのteam)にこの状態でどんなロールを持っているか」。Identity(User)をAccess(Membership)と分けることで、請負業者、オフボーディング、マルチorgアクセスが扱いやすくなります。
コードやUIで使える簡潔な定義:
- Member:orgまたはteamにアクティブなメンバーシップを持つユーザー。
- Role:命名された権限の束(例:Org Admin、Team Manager)。
- Permission:単一の許可アクション(例:「請求書を表示する」)。
- Tenant boundary:データがorgにスコープされるというルール。
メンバーシップはブールではなく小さな**状態機械(state machine)**として扱いましょう。一般的な状態は invited、active、suspended、removed です。これにより招待、承認、オフボーディングが一貫して監査可能になります。
単一のリレーショナルモデル:コアテーブルと関係
良いマルチテナントスキーマは一つの考えに始まります:「誰がどこに属しているか」を一か所に保存し、他はサポートテーブルにする。そうすれば基本的な問い(誰がorgにいるか、誰がチームにいるか、何ができるか)に、関連しないモデルをまたがず答えられます。
通常必要になるコアテーブル:
- organizations:顧客アカウント(テナント)ごとの1行。名前、ステータス、課金フィールド、不変のidを持つ。
- teams:組織内のグループ(Support、Sales、Admin)。常に1つのorganizationに属する。
- users:個人ごとの1行。これはグローバルで、orgごとではない。
- memberships:このユーザーがこのorganization(必要に応じてこのteam)に属していることを示す橋渡し。
- role_grants(または role_assignments):メンバーシップが持つロール。orgレベルやteamレベル、またはその両方で付与される。
キーと制約は厳格に保ちましょう。各テーブルにサロゲート主キー(UUIDやbigint)を使い、 teams.organization_id -> organizations.id や memberships.user_id -> users.id のような外部キーを追加します。重複を本番前に止めるいくつかのユニーク制約も加えます。
よく使うルール:
- org のスラッグや外部キーは一意:
unique(organizations.slug) - org 内のチーム名はユニーク:
unique(teams.organization_id, teams.name) - org メンバーシップの重複なし:
unique(memberships.organization_id, memberships.user_id) - チームメンバーシップの重複なし(チームメンバーシップを別モデルにする場合):
unique(team_memberships.team_id, team_memberships.user_id)
どこを追記型(append-only)にし、どこを更新可能にするかを決めましょう。Organizations、Teams、Usersは更新可能です。Membershipsは現在の状態(active、suspended)を更新することが多いですが、変更は追加型のアクセスログにも書き込んで監査を容易にするのが望ましいです。
一貫性を保つ招待とメンバーシップ状態
アクセスをきれいに保つ最簡単な方法は、招待を未完成のメンバーシップではなく独立したレコードとして扱うことです。Membershipは「ユーザーが現在属している」ことを意味し、Invitationは「アクセスを提案したがまだ実際ではない」ことを意味します。これを分けると幽霊メンバーや半端な権限、誰が招待したかの不明瞭さを避けられます。
単純で信頼できる状態モデル
メンバーシップには誰にでも説明できる小さな状態セットを使いましょう:
- active:ユーザーはorg(および所属するチーム)のアクセスを持つ
- suspended:一時的にブロック、履歴は残る
- removed:もはやメンバーではないが、監査やレポートのために保持される
多くのチームはメンバーシップに「invited」を含めず、招待を厳密にinvitesテーブルだけに保持します。これはクリーンです:membership行は実際にアクセスを持つユーザー(active)か、過去に持っていたユーザー(suspended/removed)だけを表します。
アカウントがまだない状態でのメール招待
B2Bアプリはアカウントがない状態でメールで招待することがよくあります。招待レコードにメール、適用先(orgまたはteam)、予定ロール、送信者を保存してください。後でそのメールでサインアップした場合、保留中の招待と照合して受諾させることができます。
招待が受諾されたら、トランザクション内で処理します:招待を accepted にし、membershipを作成し、監査エントリ(誰が、いつ、どのメールで受諾したか)を書きます。
招待の明確な終了状態を定義しましょう:
- expired:期限切れで受諾不可
- revoked:管理者が取り消し、受諾不可
- accepted:membershipに変換された
重複招待を防ぐために「orgまたはteamごと、メールごとに保留中の招待は1つだけ」を強制します。再招待をサポートする場合は、既存の保留中招待の有効期限を延長するか、古いものを取り消して新しいトークンを発行します。
混乱させないロールと継承
ほとんどのB2Bアプリは、組織全体で何ができるか(orgレベル)と特定チーム内で何ができるか(teamレベル)の二段階のアクセスが必要です。これらを1つの role カラムに混ぜると、一貫性が失われます。
orgレベルのロールは請求や招待の管理、全チームの閲覧権限などを決めます。teamレベルのロールは特定チーム内での編集や承認、閲覧のみといった権限を決めます。
ロール継承は次の一つのルールに従わせると扱いやすいです:orgロールは明示的に無効化しない限りどこでも適用される。これにより予測可能性が増し、重複データが減ります。
これをモデル化するシンプルな方法はスコープ付きでロール割当を保存することです:
role_assignments:user_id,org_id, optionalteam_id(NULLはorg全体を意味),role_id,created_at,created_by
「1スコープにつき1ロール」にしたければ (user_id, org_id, team_id) にユニーク制約を追加します。
チームの実効アクセスを決める手順:
-
まずチーム特有の割当(
team_id = X)を探す。存在すればそれを使う。 -
存在しなければ org 全体の割当(
team_id IS NULL)にフォールバックする。
最小権限をデフォルトにするなら、最小のorgロール(多くは「Member」)を選び、隠れた管理権限を与えないでください。新しいユーザーは明示的にチームアクセスを付与されない限りチームアクセスを得るべきではありません。自動付与する場合は、orgロールを密かに広げるのではなく、明示的なチームメンバーシップを作成してください。
オーバーライドはまれで分かりやすくするべきです。例えば、Mariaはorgでは「Manager」(招待やレポートの閲覧が可能)だが、Financeチームでは「Viewer」にしたい場合、org全体の割当とFinance用のチームスコープのオーバーライドを保存します。権限のコピーを行わず、例外が明示的に見えるようにします。
一般的なパターンにはロール名が有効です。個別の特殊ケース(「エクスポートはできるが編集はできない」など)やコンプライアンスで明確な許可リストが必要な場合のみ明示的なパーミッションを使い、それでもスコープの考え方は一貫させます。
監査に優しい変更:誰がアクセスを変えたかの追跡
メンバーシップ行に現在のロールだけを保存すると、物語が失われます。「先週の火曜日に誰がAlexに管理者権限を付与したのか?」と問われたときに信頼できる答えがないことがあります。現在の状態だけでなく変更履歴が必要です。
最も単純な方法はアクセスイベントを記録する専用の監査ログテーブルを用意することです。追加のみのジャーナルとして扱い、古い監査行を編集することはしません。
実用的な監査テーブルには通常次が含まれます:
actor_user_id(誰が変更したか)subject_typeとsubject_id(membership、team、org など)action(invite_sent、role_changed、membership_suspended、team_deleted)occurred_at(いつ起きたか)reason(任意の自由テキスト、例:「請負業者のオフボーディング」)
「前」と「後」を捉えるには、関心のあるフィールドのスナップショットを小さく保存します。アクセス制御データに限定し、ユーザープロファイル全体は入れないでください。例:before_role, after_role, before_state, after_state, before_team_id, after_team_id。柔軟性が欲しければ before と after にJSON列を使うこともできますが、ペイロードは小さく一貫性を保ちましょう。
MembershipsやTeamsでは、ハードデリートよりソフトデリートが一般的に優れています。行を削除する代わりに deleted_at と deleted_by のように無効化しておくと外部キーが保たれ、過去のアクセスを説明しやすくなります。期限切れの招待のように本当に一時的なものはハードデリートでもよい場合がありますが、あとで必要にならないと確信がある場合に限ります。
これがあれば、次のようなコンプライアンス上の問いに素早く答えられます:
- 誰がいつアクセスを付与・削除したか?
- 具体的に何が変わったか(ロール、チーム、状態など)?
- アクセスは通常のオフボーディングの一環として削除されたのか?
ステップバイステップ:リレーショナルDBでのスキーマ設計
シンプルに始めましょう:「誰がどこに属しているか」を1か所に書き、理由を持たせます。小さなステップで構築し、データが「ほぼ正しい」にならないようルールを追加していきます。
PostgreSQLなどでうまくいく実務順序:
-
organizationsとteamsを作り、安定した主キー(UUIDまたはbigint)を付けます。teams.organization_idを外部キーにし、チーム名のユニーク性を早めに決めます。 -
usersをメンバーシップから分離します。usersにアイデンティティフィールド(email、status、created_at)を置き、membershipsテーブルにuser_id、organization_id、任意のteam_id(そのようにモデル化する場合)、state(active、suspended、removed)を入れます。 -
invitationsを独立テーブルにします。organization_id、任意のteam_id、email、token、expires_at、accepted_atを保存し、「org + email + team ごとに1つの開いている招待」という一意制約を強制します。 -
ロールは明示的なテーブルでモデル化します。単純な方法は
roles(admin、member 等)と、orgスコープ(team_idなし)またはチームスコープ(team_idあり)を指すrole_assignmentsを用意することです。継承ルールは一貫してテスト可能にしておきます。 -
初日から監査トレイルを追加します。
access_eventsテーブルを作り、actor_user_id、target_user_id(招待のときはemailでも良い)、action(invite_sent、role_changed、removed 等)、scope(org/team)、created_atを保存します。
これらができたら、基本的な管理クエリをいくつか実行して現実を検証します:「誰がorg全体のアクセスを持っているか?」「管理者のいないチームはどれか?」「期限切れだがまだオープンな招待は?」など。これらの問いは早期に欠けた制約を露呈します。
データを乱さないルールと制約
スキーマが健全であるためには、コードだけでなくデータベース自体がテナント境界を強制することが重要です。最も簡単なルールは:テナントスコープのテーブルには必ず org_id を持たせ、すべての検索にそれを含めること。アプリでフィルタを忘れても、DBがクロスオーガナイゼーションの接続を抑止するべきです。
データをクリーンに保つガードレール
常に「同じorg内に参照がある」ような外部キーから始めましょう。たとえば team_memberships を別に持つ場合、team_memberships 行は team_id と user_id を参照するだけでなく org_id も持ち、複合キーで参照チームが同じorgに属することを強制できます。
一般的な防止策:
- ユーザーごとにorgに対するアクティブなメンバーシップは1つ:
unique(org_id, user_id)(部分条件でアクティブ行に限定することも) - org/チームごとの保留中招待は1つ:
unique(org_id, team_id, email)wherestate = 'pending' - 招待トークンはグローバルにユニークで再利用しない:
unique(invite_token) - チームは必ず1つのorgに属する:
teams.org_idNOT NULL とorgs(id)への外部キー - 削除ではなく終了を記録する:
ended_at(とended_by)を保存して監査履歴を保護
実際の検索のためのインデックス
アプリが頻繁に実行するクエリにインデックスを張ります:
(org_id, user_id):「このユーザーはどのorgにいるか?」(org_id, team_id):「このチームのメンバー一覧」(invite_token):「招待を受諾するための検索」(org_id, state):「保留中の招待」や「アクティブなメンバー」を管理画面で探すため
org名は変更可能にしておきましょう。orgs.id を不変の識別子として使い、orgs.name やスラッグは編集可能フィールドにします。名前変更は1行更新で済みます。
チームを別のorgに移動するのはポリシーの問題です。安全側として禁止するか(またはチームをクローンする)方が無難です。どうしても許す場合は1トランザクションで行い、org_idを持つ子テーブルをすべて更新します。
ユーザーが去ったときに孤立レコードを防ぐには、ハードデリートを避けます。ユーザーを無効化し、メンバーシップを終了させ、親行の削除を制限する(ON DELETE RESTRICT)ことで外部キーの整合性を保ちます。
例:1つのorg、2つのチーム、アクセスの安全な変更
会社名を Northwind Co とし、1つのorgと2つのチーム(Sales と Support)があるとします。請負のMiaを1か月サポートチケット対応で雇ったケースを考えます。モデルは予測可能であるべきです:一人の人、1つのorgメンバーシップ、必要に応じたチームメンバーシップ、明確な状態。
org管理者(Ava)がMiaをメールで招待します。システムはorgに紐づく招待行を作り、pending ステータスと有効期限を設定します。まだ何も変わっておらず、半端なアクセス行はありません。
Miaが受諾すると招待は accepted になり、orgメンバーシップが active で作成されます。AvaはMiaのorgロールを member(管理者ではない)に設定し、Supportチームへのメンバーシップを追加して support_agent のチームロールを与えます。
ここにひとつのひねりを加えます:Benはorgでは admin だが Support のデータは見せたくない場合、Supportに対して明示的にダウングレードするチームレベルのオーバーライドを与えられます。org全体の管理権限は維持しつつ、チームの閲覧を制限できます。
1週間後、Miaがポリシー違反で一時停止されたとします。行を削除する代わりにAvaはMiaのorgメンバーシップ状態を suspended に設定します。チームメンバーシップは残せますが、orgメンバーシップが非アクティブなため無効になります。
監査履歴は次のように残ります:
- Ava が Mia を招待した(誰が、何を、いつ)
- Mia が招待を受諾した
- Ava が Mia を Support に追加し
support_agentを割り当てた - Ava が Ben の Support オーバーライドを設定した
- Ava が Mia を一時停止にした
このモデルならUIは明確なアクセス要約を表示できます:orgの状態(active/suspended)、orgロール、チームとロールの一覧とオーバーライド、そして「最近のアクセス変更」フィードで誰がなぜ見られる/見られないかを説明できます。
避けるべき一般的なミスと罠
多くのアクセスバグは「ほぼ正しい」データモデルから生じます。初めは問題なさそうに見えても、エッジケース(再招待、チーム移動、ロール変更、オフボーディング)が積み重なって崩れます。
一般的な罠は招待とメンバーシップを1行で混ぜることです。invited と active を同じレコードで曖昧に扱うと、「受諾していないならこの人はメンバーか?」と答えにくくなります。招待とメンバーシップを分けるか、状態機械を明確にして一貫させてください。
他のよくあるミスはユーザーテーブルに単一の role カラムを置くことです。ロールはほとんどの場合スコープを持ちます(orgロール、teamロール、projectロール)。グローバルロールにすると「ある顧客では管理者だが別の顧客では読み取り専用」といったハックが生まれ、マルチテナントの期待を壊します。
後で痛い目を見るトラップ:
- 誤ってクロスorgチームメンバーシップを許す(team_idはorg Aを指し、membershipはorg Bを指す)
- メンバーシップをハードデリートして「先週誰がアクセスを持っていたか」を失う
- ユニーク制約がなく、ユーザーが重複して同じアクセスを持つ
- 継承が黙って積み重なり(org admin + team member + override)、誰もなぜアクセスがあるかわからなくなる
- 「招待が受諾された」をUIイベント扱いにしてDB上の事実にしない
例:請負業者が招待されSalesに入り、その後削除されて1か月後に再招待された。古い行を上書きすると履歴を失い、重複を許すと2つのアクティブなメンバーシップを持つかもしれません。明確な状態、スコープ付きのロール、適切な制約で防げます。
実装前の簡単チェックと次のステップ
コードを書く前にモデルを紙の上で通して、まだ意味が通るか確認してください。良いマルチテナントアクセスモデルは「退屈」なはずです:同じルールがどこでも適用され、特例が少ない。
よくあるギャップを見つけるためのチェックリスト:
- すべてのメンバーシップは正確に1つのユーザーと1つのorgを指し、重複を防ぐユニーク制約がある。
- 招待、メンバーシップ、削除の状態は明示的で(NULLに依存しない)、遷移が制限されている(例:期限切れ招待は受諾できない)。
- ロールは一か所に保存され、実効アクセスは一貫して計算される(継承ルールも含む)。
- org/team/userの削除が履歴を消さない(監査のためにソフトデリートやアーカイブを使う)。
- すべてのアクセス変更が actor、target、scope、タイムスタンプ、理由/ソースを伴う監査イベントを出す。
実際の問いで設計をプレッシャーテストしてください。これらの問いに1つのクエリと明確なルールで答えられないなら、制約か状態が足りません:
- ユーザーが二重に招待され、後でメールを変えたらどうなるか?
- チーム管理者はorgオーナーをそのチームから外せるか?
- orgロールが全チームへのアクセスを与えるとき、あるチームはそれをオーバーライドできるか?
- 招待が受け入れられた時点でロールが変わっていたらどのロールが適用されるか?
- サポートが「誰がアクセスを削除したか」と聞いたときに迅速に証明できるか?
管理者やサポート担当者が理解すべきことを書き出してください:メンバーシップ状態(何がそれをトリガーするか)、誰が招待/削除可能か、ロール継承が何を意味するか(平易な言葉で)、インシデント時にどの監査イベントを見るべきか。
まず制約を実装し(ユニーク、外部キー、許可遷移)、その上にビジネスロジックを構築してデータベースが不正を防ぐようにしましょう。継承のオン/オフ、デフォルトロール、招待有効期限などのポリシーはコード定数ではなく設定テーブルに置くと柔軟です。
もし手書きでバックエンドや管理画面を全部作りたくなければ、AppMaster (appmaster.io) はこれらのテーブルをPostgreSQLでモデリングし、招待とメンバーシップ遷移を明示的なビジネスプロセスとして実装しつつ、本番デプロイ用の実際のソースコードを生成するのに役立ちます。
よくある質問
Use a separate membership record so roles and access are tied to an org (and optionally a team), not to the global user identity. That lets the same person be an Admin in one org and a Viewer in another without hacks.
Keep them separate: an invitation is an offer with an email, scope, and expiry, while a membership means the user actually has access. This avoids “ghost members,” unclear status, and security bugs when emails change.
A small set like active, suspended, and removed is enough for most B2B apps. If you keep “invited” only in the invitations table, memberships stay unambiguous: they represent current or past access, not pending access.
Store org roles and team roles as assignments with a scope (org-wide when team_id is null, team-specific when it’s set). When checking access for a team, prefer the team-specific assignment if it exists, otherwise fall back to the org-wide one.
Start with one predictable rule: org roles apply everywhere by default, and team roles override only when explicitly set. Keep overrides rare and visible so people can explain access without guessing.
Enforce “only one pending invite per org/team per email” with a unique constraint and a clear pending/accepted/revoked/expired lifecycle. If you need re-invites, update the existing pending invite or revoke it before issuing a new token.
Every tenant-scoped row should carry org_id, and your foreign keys/constraints should prevent mixing orgs (for example, a team referenced by a membership must belong to the same org). This reduces the blast radius of missing filters in application code.
Keep an append-only access event log that records who did what, to whom, when, and at what scope (org or team). Record the key before/after fields (role, state, team) so you can answer “who granted admin last Tuesday?” reliably.
Avoid hard deletes for memberships and teams; mark them ended/disabled so history remains queryable and foreign keys don’t break. For invites, you can keep them too (even if expired) if you want a full security trail, but at minimum don’t reuse tokens.
Index your hot paths: (org_id, user_id) for org membership checks, (org_id, team_id) for team member lists, (invite_token) for invite acceptance, and (org_id, state) for admin screens like “active members” or “pending invites.” Indexes should reflect your real queries, not every column.


