マルチテナントのSaaSを作っていて一番こわいのが、テナントAのユーザーがテナントBのデータを見られてしまう事故だ。機能のバグは謝って直せば済むことも多いが、他社のデータが見えたとなると、規模に関係なく一発で信用を失う。
やっかいなのは、この手の漏れがテストではなかなか引っかからないことだ。自分のデータを自分で見るテストは普通に通る。誰も他人のIDを指定して叩いてみないので、分離が抜けていても気づかないまま本番まで行ってしまう。
この記事は、個人で運用しているマルチテナントのSaaS(Backlogの課題からGitHubのDraft PRを作るサービス)でテナント分離をどう組んだか、その勘所をまとめたもの。フレームワークやDBに強く依存する話ではないので、考え方として読んでもらえればと思う。前提はFastAPI + PostgreSQL + SQLAlchemyだが、肝は「未確定のときにどちらへ倒すか」という一点に尽きる。
基本方針: 迷ったら拒否(fail-closed)
分離を組むうえで最初に決めたのは、「テナントが確定できないリクエストは通さない」という方針だ。
認証まわりやアクセス制御のコードは、条件分岐が増えるとどこかに「判定できなかったケース」が生まれる。セッションが無い、トークンが壊れている、IDがどこにも入っていない、といった状態だ。ここで「とりあえず通す」方向に倒すと、想定外の入力が来たときに穴になる。逆に「確定できないなら拒否」に倒しておけば、漏れても安全側に転ぶ。
具体的には、テナントを表すコンテキストを解決する関数が、確定できないときに None を返すようにしておく。そして None を受け取った側は、無条件で401を返す。
def get_account_context(ctx: AccountContext | None = Depends(resolve_context)) -> AccountContext:
# テナントが確定できない(未認証・コンテキスト解決失敗)なら通さない
if ctx is None:
raise HTTPException(status_code=401, detail="Authentication required")
return ctx
ポイントは、「None のときどうするか」をコンテキストを使う全経路で同じに揃えることだ。ある経路では拒否、別の経路ではうっかり処理を続行、となっているとそこが穴になる。テナントが取れなかったら必ず止まる、を一箇所のガードに集約しておく。
テナントIDの正本はサーバーが持つ
次に決めたのが、「どのテナントとして振る舞うか」をクライアントの言い値で決めないことだ。
開発初期は、リクエストのヘッダやクエリパラメータに account_id を載せてもらって、それでテナントを判定していた。手軽なのだが、これは「自分は別テナントです」と名乗れば通ってしまう。署名つきのセッショントークンを正本にして、その中の account_id だけを信じるように変えた。
クライアントが指定したIDとセッションのIDが食い違ったら、どちらかを優先するのではなく拒否する。「セッションはAなのにヘッダはB」というリクエストは、まともな使い方では起きないので、エラーで弾く。
def resolve_account_context(*, client_account_id, session_account_id, session_user_id):
# ログインセッションがあればそれが正。クライアント指定が食い違えば拒否
if session_account_id:
if client_account_id and client_account_id != session_account_id:
raise HTTPException(status_code=403, detail="Conflicting account_id values")
return AccountContext(account_id=session_account_id, user_id=session_user_id)
return None # セッションが無ければテナント未確定 → 上位で401
開発中の手軽さのためにヘッダ/クエリのフォールバックを残す場合でも、本番ではその経路を無効にする設定を用意した。デフォルトを「フォールバック無効」にしておけば、設定を入れ忘れても安全側に倒れる。if 環境変数 == "production" のように特定の値のときだけ締めるのではなく、明示的に許可しない限り締まっているのが望ましい。
もうひとつ地味だが大事なのは、「誰が操作したか」も同じくセッション由来の値を正本にすること。問い合わせの送信者や、レコードの作成者をクライアント送出値で埋めると、他人のIDを名乗ったなりすましができてしまう。セッションから取れた user_id だけを保存に使い、セッション経路でないときは None(保存しない)に倒す。
クエリは必ずテナントで絞る
入口でテナントを確定させても、データを引くクエリがテナントで絞られていなければ意味がない。WHERE id = :id だけで引いてしまうと、他テナントのレコードのIDを推測(あるいは総当たり)されれば中身が返る。
なので、テナントに属するテーブルを引くときは、id だけでなく必ず account_id も条件に入れる。
stmt = (
select(TicketProcessingRun)
.where(TicketProcessingRun.id == run_id)
.where(TicketProcessingRun.account_id == ctx.account_id) # テナントで必ず絞る
)
これを毎回手で書くと、いつか1箇所で書き忘れる。なので、テナント所有のテーブルを引く取得関数は、account_id を必須引数にしたリポジトリ層に寄せておく。「テナントを指定せずに引けてしまう関数」を作らないのがいちばん効いた。get_run(run_id) のような関数があると必ずどこかで生で呼ばれるので、get_run(account_id, run_id) の形しか存在しないようにする。
取得時に絞れない経路(先にIDだけで引いて、後から所有者をチェックする作り)には、共通の所有権ガードを通す。ここでもコンテキストが None なら拒否、というfail-closedを徹底する。
def require_account_access(ctx: AccountContext | None, resource_account_id: str) -> None:
if ctx is None:
raise HTTPException(status_code=401, detail="Authentication required")
if ctx.account_id != resource_account_id:
raise HTTPException(status_code=403, detail="Account access denied")
なお、見つからなかった(404)と、他テナントのものだった(403)を厳密に区別すると、「そのIDのリソースは存在する」という情報が漏れる。気にする場面では、どちらも同じ404に寄せてリソースの存在自体を隠す、という選択もある。サービスの性質に合わせて決めればいい。
エラーレスポンスとログから情報を漏らさない
分離の判定を間違えたときに何を返すかも、地味に効いてくる。
「指定されたテナントは存在しません」と「権限がありません」を区別して返すと、どのテナントIDが実在するかを外から探れてしまう。存在しないテナントと、権限の無いテナントは、同じエラーにして区別させないほうがいい。
ログとエラーレスポンスにトークンやセッションの中身を出さないのも当然のこととして徹底する。デバッグのつもりで logger.info(f"context={ctx}") のように丸ごと吐くと、user_idやテナントIDがログに残る。出すなら必要な識別子だけにとどめる。
テストは「他人のIDで叩く」を必ず書く
冒頭に書いたとおり、テナント分離の抜けは普通のテストでは通ってしまう。なので、わざと別テナントとして振る舞うテストを明示的に書く。
- 自分のリソースは取れる(正常系)
- 他テナントのリソースのIDを指定すると403(または404)になる
- セッションが無いと401になる
- セッションのIDとクライアント指定のIDが食い違うと拒否される
- なりすまし用に他人のuser_idを送っても、保存されるのはセッション由来のIDだけ
このうち効くのは2番目以降だ。「他人のIDを指定して叩いたら、ちゃんと拒否されること」をテストにしておくと、リポジトリ層の絞り込みを1箇所外したときにテストが落ちて気づける。新しいエンドポイントを足すたびにこの一式を書く、というルールにしておくのがいい。
まとめ
マルチテナントのテナント分離でやったことの要点はこのあたり。
- テナントが確定できないリクエストは通さない(fail-closed)。
Noneの扱いを全経路で「拒否」に揃える。 - テナントIDの正本はサーバーが持つ。クライアントの言い値で振る舞いを変えない。フォールバックはデフォルト無効。
- テナント所有テーブルを引くクエリは必ずテナントで絞る。テナント指定なしで引ける関数を作らない。
- 存在しない/権限が無いを区別せず、リソースの存在自体を漏らさない。
- 「他人のIDで叩く」テストを必ず書く。分離が抜けたときに落ちるテストにする。
この手の分離は、自前でミドルウェアやリポジトリ層を組めば実現できる話で、特別な仕組みはいらない。自分はBacklogの課題からGitHubのDraft PRを作る keros というサービスをマルチテナントで運用していて、ここに挙げた分離はそこで実際に使っている設計をならしたものだ。テナントが増えてからやり直すのは大変なので、最初から「迷ったら拒否」で組んでおくのをおすすめしたい。