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 が教えてくれます。

まとめ

共有データベース・テナンシーは運用コストが安く、漏洩しやすいものです。解決策は注意深くなることではなく、「覚えていたか?」を失敗するテストに変えることです。リゾルバー、スコープ、ユニーク性が可動部品であり、ガードレールがあなたを眠らせるものです。