Identity Platformのマルチテナンシー機能を本番環境で運用した学び
こんにちは、エンジニアの加藤(@tomo_k09)です。
PharmaXが提供する薬局オペレーションを効率化するためのシステムを提供しています(以下、薬局OS)。
このサービスではFirebaseAuth + Identity Platformのマルチテナンシー構成でテナントごとにユーザーの管理を行なっています。
この記事では、FirebaseAuth + Identity Platformのマルチテナンシー構成でテナントごとにユーザー管理をしてみて感じたことをまとめてみました。
マルチテナンシーとは
マルチテナンシーは、1つのソフトウェアやサービスを複数のユーザー(テナント)が独立して利用できる環境を提供するという概念のことです。
例えば、Google WorkspaceやMicrosoft 365などのオフィスツールは、一つのアプリケーションを複数の企業が同時に利用できますが、各企業のデータは他の企業のデータと分離されています。
つまり、各企業は自分たちのドキュメント、メール、スケジュールなどを管理し、他の企業が同じサービスを使用していることを意識することなく、安全に業務を遂行できます。
マルチテナンシーのメリット
- サービス提供者は一つのシステムを複数の顧客で共有するため、リソースを効率的に利用できます
- 各顧客に個別のインフラを用意する必要がなくなるため、運用コストが削減されます
Identity Platformにおけるマルチテナンシーとは
Identity Platformは、Googleが提供する認証サービスで、Firebase Authenticationのアップグレード版といえます。Firebase Authenticationの機能に加え、以下のような機能を提供します。
<Identity Platformの主な機能>
- 認証フローのカスタマイズ
- マルチテナンシー
- OIDC認証とSAML認証のサポート
- 99.95%の稼働時間SLA
詳細はこちらの記事をご覧ください。
重要なのは、Identity Platformのマルチテナンシー機能と、一般的なアーキテクチャのマルチテナンシーは異なるという点です。
一般的なアーキテクチャのマルチテナンシーは、一つのソフトウェアやアプリケーションが複数のテナントに共有されることを指し、リソースの効率的な利用やコスト削減に寄与します。
一方、Identity Platformのマルチテナンシー機能は、テナントごとにログイン方法や設定を分けることができる機能です。
つまり、一般的なマルチテナンシーはアプリケーション全体の共有と運用が焦点であるのに対し、Identity Platformではユーザー管理と認証の柔軟性に焦点を当てています。
PharmaXではどのようにIdentity Platformを使っているかの紹介
薬局OSにはさまざまな機能がありますが、今回はFirebaseAuth + Identity Platformと密接に関係しているチャット機能に焦点を当てて紹介できればと思います。
薬局OSでは複数の薬局さんに導入される前提で設計されています。
つまり、薬局OSにログインする薬剤師さんは自分の薬局を利用している患者さんのチャットしか見られないようにする必要があるということです。
そこで採用したのがFirebaseAuth + Identity Platformによるマルチテナンシー構成です。
Firebaseのセキュリティルールで、該当の薬局に所属する薬剤師のみが該当薬局を利用している患者さんとのやチャットを閲覧できるようにしています。(下記のようなイメージです)
薬剤師さんはIdentity Platformで認証を行い、セキュリティルールを通して、Firestoreに保存されている患者さんとのやりとりを閲覧できるようになっています。
なぜ認証サービスとしてIdentity Platformを選んだのか
薬局OSは、元々自社薬局向けに開発されたシステムであり、Firebase Authenticationを利用して認証を行っていました。
しかし、他の薬局にも薬局OSを導入できるようにするという事業上の方針転換があり、後付けでマルチテナンシー構成を取る必要が生じました。
この変更に伴い、認証サービスの選定が重要な課題となりました。検討した主要な候補は以下の3つです。
- Identity Platform
- Amazon Cognito
- Auth0
各サービスにはそれぞれ利点がありますが、既存のシステムがFirebaseを使用していたため、Firebaseとシームレスに統合できるIdentity Platformが最も適していると判断しました。具体的には、以下の理由からIdentity Platformを選択しました。
<Identity Platformを選択した理由>
- Firebase Authenticationからの移行がスムーズに行えるため、既存のインフラストラクチャやデータをそのまま活用できる。
- Firebaseのアドオンとして利用できるため、設定や管理が容易である。
Identity Platformのマルチテナンシー機能を使う際の留意事項
Identity Platformを使ってみた感じたことについては多々あるのですが、この記事ではIdentity Platformを使う前に留意しておくと良さそうだなということにフォーカスして紹介します。
テナントごとのユーザー管理の設定が簡単
リファレンスに沿ってボタンをポチポチ押していくだけで、テナントの作成を簡単にできます。
どれだけ簡単かはGoogle Cloudさんの記事を読むとイメージがつきやすいかと思うので、ぜひチェックしてみてください。
SDKがサポートされていない言語で実装すると少し時間がかかる
SDKがサポートされている言語が限られている点は注意が必要です。
リファレンスを読む限り、以下の5つの言語だけSDKがサポートされています。
<SDKがサポートされている言語>
- Node.js
- Java
- Python
- Go
- C#
PharmaXのバックエンドはRuby on Railsで動いており、Identity Platformのユーザーを作成する際はフロントエンドからユーザー作成用のRails APIを叩いています。
そのため、SDKを利用できないRailsの場合は、GoogleのAPIを叩くための実装が追加で必要となるので、SDKを使う場合に比べると少し導入コストが高いでしょう。
ちなみに私たちはgoogle-apis-identitytoolkit_v3というgemを使って実装しました。
なぜか管理画面の検索フィルタがうまく動かなかった
Identity Platformの管理画面では、検索フィルタが設置されています。
あるユーザーのパスワードをリセットする必要が出てきた際、私の検索の仕方が悪かったのか検索フィルタが動かないことがわかり、1ページずつ確認して該当ユーザーを探す必要がありました。
Identity Platformで管理するユーザーをコンソール画面から検索して何か作業をするということが頻繁に起こることが想定される場合は、Googleから提供されている検索APIを使って、何かしらの対策を取る必要がありそうです。
テナントが追加されるたびにセキュリティルールも追加する必要があった
PharmaXでは、先ほどもご紹介した通り、薬局Aに所属する薬剤師は薬局Aの患者チャットしか閲覧できないようになっています。
このセキュリティルールは、Firebaseのリファレンスにならって以下のように設定しています。つまり、特定のtenantIDだったらwriteやreadを許可するというイメージです。
service cloud.firestore {
match /databases/{database}/documents {
// For tenant-based access control, check for a tenantID
allow write: if request.auth.token.firebase.tenant == 'tenant2-m6tyz';
allow read: true;
}
}
引用:https://firebase.google.com/docs/rules/basics?hl=ja
ただしことのやり方だと、テナントが追加されるごとにセキュリティルールを設定することになるため、運用面で少しつらいことになります。
PharmaXの場合、直近テナント数が急激に増える想定がなかったのであまり問題となりませんでしたが、テナントが一気に増えることが想定される場合は、Firebaseのセキュリティルールを使うのではなく、他の方法を考えた方が良いかもしれません。
*セキュリティルールの運用方法などについては、弊社エンジニアの[@hakoten]の記事が参考になるかと思います。
テナント管理のスケーラビリティを考慮した場合どのように実装するべきだったか
Identity Platformはテナントの作成を簡単にできるのは良いのですが、結局データベースにテナントの情報を持っておかないといけないケースがあり、データベースでアクセスの制限をかけると管理しやすいでしょう。
私たちのサービスのバックエンドはRuby on Railsを採用しているので、Railsでテナントごとにユーザーを管理したい場合、どのように実装したら良さそうだったかを考えてみました。
activerecord-multi-tenantを使う
ActiveRecordレベルでテナントを分離することができるようになるためのGemです。
このGemを使用すると、ActiveRecordモデルにテナントコンテキストを追加するので、データの分離が簡単にできるようになります。
モデルにmulti_tenantを定義し、テナントごとにデータを分けます。
class User < ActiveRecord::Base
multi_tenant :tenant
end
class Tenant < ActiveRecord::Base
has_many :users
end
MultiTenant.withのように書くと、そのテナントに紐づくレコードの操作が可能です。
tenant = Tenant.find(params[:tenant_id])
MultiTenant.with(tenant) do
User.create(name: 'John Doe')
end
このようにactiverecord-multi-tenantを使うと、テナントごとのデータ分離をうまくできるようになります。
しかし、直接SQL実行をする場合(例えばActiveRecord::Base.connection.execute(sql)を使うとき)には、データの分離がうまくいかないという欠点があります。
一つのミスが致命的なセキュリティ事故につながるテナント間のデータの分離だと、多層防御という考え方が必要になりそうです。
このように、アプリケーションレベルでのデータ分離に加え、データベースレベルでのデータ分離も考える必要があります。そのため、後述のPostgreSQLが提供している行セキュリティポリシーをを使用することで、データベースレベルでのデータ分離を実現します。
PostgreSQLの行セキュリティポリシーを使う
PostrgreSQLの行セキュリティポリシーを利用すると、テナントが増えるごとにFirebaseへセキュリティルールを追加しなくてすみます。
行セキュリティポリシーとは、データベースのテーブルに対して、どの行が表示されるか、どの行が更新や削除できるかをユーザーごとに制限する仕組みです。
この機能によって、ユーザーがアクセスできるデータの範囲を細かく制御できます。
<行セキュリティポリシーについて>
この行セキュリティポリシーをRuby on Railsで使うには、activerecord-tenant-level-securityというgemが便利です。
activerecord-tenant-level-securityを使う
activerecord-tenant-level-securityは、PostgreSQLの行レベルセキュリティを利用してテナントベースのデータ管理を行うためのGemです。このGemを使用すると、データベースレベルでセキュリティを確保しながら、テナントごとにデータアクセスを制御できます。
前述のactiverecord-multi-tenantと組み合わせることで、「アプリケーション層での防御」と「データベース層での防御」による多層防御が簡単に実現できるようになります。
以下のように、テーブルにポリシーを設定します。
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.integer :tenant_id
t.string :name
end
create_policy :users
end
end
TenantLevelSecurity.withを使用します。
tenant = Tenant.find(params[:tenant_id])
MultiTenant.with(tenant) do
TenantLevelSecurity.with(tenant.id) do
user = User.create(email: 'example@example.com', name: 'Example User')
end
end
実装イメージ
雑ではありますが、以下のような実装イメージです。
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email
t.string :name
t.integer :tenant_id, null: false
t.timestamps
end
add_index :users, :tenant_id
end
end
class User < ActiveRecord::Base
multi_tenant :tenant
validates :email, presence: true
validates :name, presence: true
end
class Tenant < ActiveRecord::Base
has_many :users
end
class ApplicationController < ActionController::Base
set_current_tenant_through_filter
before_action :set_tenant
private
def set_tenant
tenant_id = request.headers['X-Tenant-ID']
tenant = Tenant.find(tenant_id)
set_current_tenant(tenant)
end
end
class UsersController < ApplicationController
def index
@users = User.all
render json: @users
end
def create
@user = User.new(user_params)
@user.save!
render json: @user
end
private
def user_params
params.require(:user).permit(:email, :name)
end
end
tenant = Tenant.find(params[:tenant_id])
MultiTenant.with(tenant) do
TenantLevelSecurity.with(tenant.id) do
user = User.create(email: 'example@example.com', name: 'Example User')
end
end
終わりに
この記事では、Identity Platformのマルチテナンシー構成で本番運用した感想について書きました。Identity Platformはどんどん機能が拡充しているので、これからの発展がとても楽しみです。
Identity Platformのマルチテナント機能で本番運用してみて、テナント管理のスケーラビリティというつらみはありましたが、改めてテナント管理の方法を考えてみるとこれらの問題はちゃんと解消できそうでした。
今後、マルチテナント構成でアプリケーションの開発をする場合は、多層防御という考え方を取り入れて実装したいと思います。
PharmaXでは、様々なバックグラウンドを持つエンジニアをお待ちしております。
もし興味をお持ちの場合は、私のXアカウント(@tomo_k09)までお気軽にメッセージをいただけますと幸いです。まずはカジュアルにお話しましょう!
PharmaXエンジニアチームのテックブログです。エンジニアメンバーが、PharmaXの事業を通じて得た技術的な知見や、チームマネジメントについての知見を共有します。 PharmaXエンジニアチームやメンバーの雰囲気が分かるような記事は、note(note.com/pharmax)もご覧ください。
Discussion