マルチテナントシステムは、公開してしばらく経つと必ず同じ問題に遭遇します。あるテーブルのクエリから tenant_id 条件が漏れていた、という問題です。コードレビューではまったく正常に見えます —— SQL の構文に問題はなく、業務ロジックも筋が通っている。ただ、本来は見えるはずのない行が余分に返ってくるだけです。
この種の問題の根源は、セキュリティ境界を「開発者が覚えて書く」という前提に置いていることにあります。人は忘れるし、新しい人は知らないし、リファクタリングで失われます。だから私たちのやり方は、テナント述語の強制チェックをデータアクセス層へ降ろすことです。
散らばった記憶ではなく、一箇所での宣言
最初のステップは、「どのテーブルがテナントスコープに属するか」を一つの集中レジストリに変えることです。45 のテーブルを TenantScopedTables で一度だけ宣言します。数十の Mapper に散らばらせて慣習に頼る、というやり方ではありません。
01// 集中注册:租户作用域表是一份清单,不是隐式约定02object TenantScopedTables {03 val ALL: Set<String> = setOf(04 "sys_user", "sys_role", "sys_permission",05 "sys_dept", "sys_space", "sys_audit_event",06 // …共 45 张07 )08}欠落した述語をクエリ層で捕捉する
次のステップは、jOOQ に毎回のクエリでチェックさせることです。この SQL はテナントスコープのテーブルに触れているか? 触れているなら、WHERE にテナント述語は付いているか? 付いていなければ、設定に従ってセキュリティログを記録するか、その場でエラーにします。
01override fun visit(ctx: VisitContext): Queries? {02 if (!touchesTenantScopedTable(ctx.query())) return null0304 if (!hasTenantPredicate(ctx.query())) {05 // DETECT_LOG:只记录,用于灰度观察06 // DETECT_THROW:直接失败,生产环境默认07 when (mode) {08 DETECT_LOG -> audit.missingTenantPredicate(ctx)09 DETECT_THROW -> throw MissingTenantPredicateException()10 }11 }12 return null13}唯一の信頼できる情報源はトークンであって、リクエストヘッダではない
tenant_id は JWT だけから取ります。リクエストヘッダにもテナントが宣言されていて、両者が一致しない場合はその場で拒否します —— リクエストヘッダは呼び出し側が好きに書けるものであり、トークンはそうではないからです。
- TenantContextFilter が JWT から tenant_id をパースし、リクエストコンテキストへ書き込む
- ヘッダの宣言とトークンが不一致 → TenantMismatchException(403)
- テナントを跨ぐ必要が本当にあるシステムレベル操作は runAsSystem による明示的なバイパスを通し、バイパス自体も監査に入る
- リクエスト終了時に UserContext をクリアし、スレッドプールの再利用によるリクエスト間リークを防ぐ
代償は何か
この仕組みは無料ではありません。すべてのテナントスコープテーブルに統一されたテナント列名の規約を求めますし、テナントを跨ぐ必要が本当にあるクエリを書く開発者には、バイパス経路を明示的に呼び出すことを求めます —— 条件なしのクエリを何気なく書くより、ずっと面倒です。
私たちはこの面倒を受け入れます。マルチテナントシステムにおいては、「少しの面倒」と「データ漏洩」は同じ桁の問題ではないからです。