TL;DR
- 共有データベース・マルチテナンシー = 1つのデータベース、すべての行に
tenant_idを付与、自動的にフィルタリングするグローバルスコープ。 - 3つの可動部品:
TenantContextシングルトン、BelongsToTenantトレイト、複合ユニーク制約。 - 本当の安全網は規律ではなく、
tenant_id列を持つテーブルがトレイトを忘れた(またはその逆)ときに失敗する CI テストです。
今週、共有データベース・マルチテナンシーの基盤レイヤーをリリースしました。ルーティングやリゾルバーではなく、「1行が1つの組織に属する」ことを真実かつ間違えにくいものにする部分です。その形をご紹介します。
なぜ共有データベースか
3つの主要な戦略が存在します。簡単なトレードオフ:
| 戦略 | 分離度 | 運用コスト | 適している場合 |
|---|---|---|---|
| Database-per-tenant | 最も強い | 最も高い(マイグレーション × N) | 少数の大規模テナント、厳格なコンプライアンス要件 |
| Schema-per-tenant | 強い | 中程度 | Postgres、中程度のテナント数 |
Shared DB + tenant_id
|
最も弱い | 最も低い | 多数のテナント、1つのコードベース、1回のマイグレーション実行 |
1つのコードベースを共有する多数の組織を持つグリーンフィールドアプリでは、共有データベースがシンプルさで勝ります。落とし穴: 分離は今やあなたの責任で、アプリケーションコードで強制されます。1つの where tenant_id = ? を忘れると、ある組織が別の組織のデータを見てしまいます。そこで設計全体は、それを忘れにくくすることです。
コンテキストオブジェクト
すべてが現在のテナントを1つのシングルトンから読み取ります。リクエスト、セッション、グローバルからではありません。これにより、リゾルバーを交換可能に保てます — オンプレミスでは設定ベース、SaaSではドメインベース — 下流の何も触らずに。
class TenantContext
{
private ?Tenant $tenant = null;
private bool $scopeDisabled = false;
public function id(): ?int { return $this->tenant?->getKey(); }
public function has(): bool { return $this->tenant instanceof Tenant; }
public function shouldScope(): bool
{
return ! $this->scopeDisabled && $this->has();
}
public function withoutScope(Closure $callback): mixed
{
// suspend scoping for genuine cross-tenant work, then restore
}
}
Enter fullscreen mode Exit fullscreen mode
shouldScope() がテナントが解決されていないときに false を返すことは重要です: コンソールブートストラップ、シーディング、初期テストはテナントが存在する前に実行されます。テナントが存在しない = フィルタリングするものが存在しない — 「null でフィルタリングする」ではありません。
トレイト
1つのトレイトが2つの仕事を行います: 読み取り時にグローバルスコープを追加し、書き込み時に tenant_id を埋めます。
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
$context = app(TenantContext::class);
static::addGlobalScope('tenant', function (Builder $q) use ($context) {
if (! $context->shouldScope()) return;
$q->where($q->getModel()->getTable().'.tenant_id', $context->id());
});
static::creating(function ($model) use ($context) {
if ($model->tenant_id === null && $context->has()) {
$model->tenant_id = $context->id();
}
});
}
// ...
}
Enter fullscreen mode Exit fullscreen mode
カラムをテーブル修飾する(table.tenant_id)ことで、2つのスコープ付きテーブルを結合した瞬間に ambiguous-column エラーを回避できます。
ユニーク性の微妙な点
共有データベースの下では、「ユニーク」が2つに分かれます。テナントが再利用する人間が読める識別子はテナントごとにユニークである必要があります。セキュリティトークンはグローバルにユニークである必要があります — そこで衝突が発生すると、UX の小さい問題ではなく、攻撃面となります。
| カラムタイプ | スコープ | 例 |
|---|---|---|
| 人間が読める識別子 | unique([tenant_id, x]) |
会員番号、請求書番号、スラッグ |
| UUIDとトークン | グローバルにユニーク | QRトークン、べき等性キー、ゲートウェイ参照 |
ユーザーも中央に留まります: 1つのメールアドレス、1つのログイン、多くの組織にまたがるメンバーシップはピボット経由。人はテナントスコープされません; 彼らのメンバーシップがスコープされます。
実際に安全を保つ部分
トレイトと制約は、それらを適用する記憶力と同じくらいしか役に立ちません。そこでガードレールはチェックリストではなく、テストです:
it('scopes every model whose table carries tenant_id', function () {
$missing = [];
foreach (tenancyModelClasses() as $class) {
$table = (new $class)->getTable();
if (Schema::hasColumn($table, 'tenant_id') && ! usesBelongsToTenant($class)) {
$missing[] = $class;
}
}
expect($missing)->toBe([]); // CI fails, names the offenders
});
Enter fullscreen mode Exit fullscreen mode
これは両方向をチェックします: トレイトのない tenant_id テーブル、およびカラムのないトレイト。6ヶ月後に新しいモデルを追加し、トレイトを忘れても、レビュー前に CI が教えてくれます。
まとめ
共有データベース・テナンシーは運用コストが安く、漏洩しやすいものです。解決策は注意深くなることではなく、「覚えていたか?」を失敗するテストに変えることです。リゾルバー、スコープ、ユニーク性が可動部品であり、ガードレールがあなたを眠らせるものです。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.