「会社のメールアドレスで登録するだけで、自動的に同じチームに入る」。on-board typingのこの体験は、機能として説明すると一行で終わる。けれど実装してみると、素朴に作ったのでは壊れるケースがあることに気づかされた。
素朴な実装だと何が起きるか
「このドメインの会社が既に存在するか確認する→なければ新規作成する→ユーザーをそのチームに紐付ける」という手順を、フロントエンドから順番にAPIを呼んで実装するとどうなるか。同じ会社ドメインの2人が、ほぼ同時に初めて登録したとする。両方とも「まだ会社が存在しない」ことを確認した直後に、それぞれが新しい会社を作ってしまう。結果、同じ会社のはずの2人が、別々のテナントに分かれてしまう。
これはTOCTOU(Time-of-Check to Time-of-Use、検査から使用までの競合状態)と呼ばれる典型的な罠だ。「確認した時点」と「実際に使う(作成する)時点」の間に、他の処理が割り込む余地がある限り、この種の不整合はいつか必ず起きる。
1つの関数の中に閉じ込める
対策として、確認と作成をフロントエンドの2段階処理に分けるのをやめ、`register_with_domain`というSECURITY DEFINER関数の中で、確認から作成・紐付けまでを1つのデータベーストランザクションとして完結させることにした。PostgreSQL側でアトミックに処理される以上、途中に他のリクエストが入り込む隙間がなくなる。フロントエンドは、この関数を呼んで結果を受け取るだけでよい。
SECURITY DEFINER関数を書くときは、もう1つ気をつけていることがある。`search_path`を明示的に`public`へ固定することだ。これを付け忘れると、呼び出し側が`search_path`を細工することで意図しないテーブルや関数を参照させられる、という既知の脆弱性パターンになる。Supabaseのセキュリティアドバイザーもこのパターンを警告してくれるので、新しいSECURITY DEFINER関数を書くたびに、この一行を書き忘れていないか必ず確認するようにしている。
company_idを受け取る関数は、なおさら慎重に
もう1つ見落としやすいのが、PostgRESTはpublicスキーマの関数をデフォルトでRPCとして外部公開し、PostgreSQLはEXECUTE権限をデフォルトでPUBLIC(anon・authenticatedを含む)に付与するという仕様だ。つまり、テナント境界をまたぐ`company_id`のようなパラメータを受け取る内部関数を何も考えずに書くと、任意のユーザーが他社のcompany_idを指定して呼び出せる状態になりかねない。こうした関数には`REVOKE EXECUTE ... FROM PUBLIC, anon, authenticated`を明示し、意図した経路以外から呼べないようにしている。
「会社メールで登録するだけ」というシンプルな体験を成立させるために、見えないところでは競合状態と権限の両方を潰しておく必要があった。派手な機能ではないが、ここが崩れるとテナント分離そのものが意味をなさなくなる。地味だけど、一番気を抜けない部分だと思っている。