このページの見出し
tenant-pass がどう動き、なぜそう作ってあるのかを書いたページです。何を防ぎ何を防がないのかは、脅威モデル に分けてあります。
構成する部品
| 部品 | 持っているもの | 役割 |
|---|---|---|
ゲートウェイ(利用者のプロキシの後ろに置く tenant-pass-gateway、または利用者が独自に作るもの) | ゲートウェイの Ed25519 の 秘密 鍵 | エンドユーザーのセッションがどのテナントのものかを確かめ、テナントと転送先のアプリケーションを書いた有効期間の短いパスに署名します。tenant-pass-gateway をご覧ください。 |
| ジョブの発行者(任意。スケジューラーなど、利用者が用意するもの) | 発行者の Ed25519 の秘密鍵 | どのリクエストからも始まらない定期的な処理のために、ジョブチケットに署名します。バックグラウンドジョブ(ジョブチケット) をご覧ください。 |
| KMS(任意。Vault / OpenBao) | ローテーションキーと署名鍵 | HMAC と署名を KMS の中で計算するので、鍵が KMS の外に出ません。鍵管理(Vault / OpenBao Transit) をご覧ください。 |
| アプリケーション(利用者が用意するもの) | 自分のアプリトークン、クライアント証明書、またはその両方 | リクエストとパスを受け取り、パスをテナントの認証情報と交換して、テナントのデータベースに接続します。 |
| tenant-pass | ゲートウェイの 公開 鍵、アプリトークンのハッシュ、ローテーションキー、ストアの管理者の認証情報 | アプリケーションとパスを検証し、テナントを特定して、認証情報をストアに設定してから返します。 |
| ディレクトリ(設定ファイルに書く静的な一覧、または利用者の PostgreSQL / MySQL) | テナントごとのノード、アカウント、有効か無効か、許可するアプリケーション | どのアカウントがどのテナントのものかを決める、正式な情報源です。 |
| ストア(PostgreSQL、MySQL、Redis) | テナントのアカウント | tenant-pass が設定したパスワードでログインを判定します。 |
要になるのは、ゲートウェイの秘密鍵とローテーションキーを別々の場所に置くことです。ゲートウェイはパスに署名できてもパスワードは導出できず、tenant-pass はパスワードを導出できてもパスには署名できません。アプリケーションはどちらの鍵も持ちません。
バックグラウンドジョブ
パスの有効期間は数秒しかないので、リクエストが終わった後で動く処理はパスを使えません。そうした処理のためにあるのがジョブチケットです。中身は PASETO のトークンで、パスとは別の暗黙のアサーション(tenant-pass/v1/job)で署名します。テナント ID を必ず含み、有効期間は長くても job_tickets.max_ttl までです。
チケットの出どころは 2 通りあります。1 つは tenant-pass 自身で、パスと引き換えに発行します(POST /v1/job-tickets。アプリケーションごとに有効にした場合だけ)。もう 1 つは、利用者が運用する信頼された発行者(スケジューラーなど)が署名したチケットです。どちらのチケットも、認証情報に交換するときにはパスと同じようにディレクトリとストアを確かめます。レート制限はパスとは別に数えます。詳しくは バックグラウンドジョブ(ジョブチケット) をご覧ください。
リクエストの流れ
アプリケーションが POST /v1/credentials を呼ぶと、tenant-pass は次の順に処理します。
- HTTP のメソッド、URL のパス、Content-Type を確認します。
- アプリトークン(
Authorization: Bearer tpa_…)でアプリケーションを認証し、どのアプリケーション(たとえばbilling)からの呼び出しかを決めます。 - リクエストボディ(最大 4 KiB)を厳格に解析します。
- パスを検証します。鍵 ID からゲートウェイの公開鍵を選び、署名、クレーム、有効期間を確かめ、
audが手順 2 のアプリケーション(billing)と一致することを確かめます。 - アプリケーションごとに、1 分間に問い合わせるテナントの数を制限します。
- ホスト名(またはテナント ID)とアプリケーションでディレクトリを調べ、テナント ID、ノード、アカウント、有効かどうかを得ます。
- ストアのアカウントに認証情報を設定します。アカウントをロックし、安全性チェックをして、現在の状態を読み取ります。次に設定するウィンドウを決め(ウィンドウを過去に戻すことはありません)、必要な場合だけ書き込みます。
- 認証情報をアプリケーションに返し、監査ログを 1 行書きます。監査ログにシークレットは決して含めません。
どの手順でも、確かめられないときは拒否します。クライアントには決まったエラーコード(invalid_pass、denied など)だけを返し、理由は監査ログにだけ書きます。
パス
パス(pass)は PASETO v4.public のトークン(Ed25519 の署名)です。仕様の全体は パスの形式(tenant-pass/v1) にあり、ここでは設計の判断とその理由だけを書きます。
JWT ではなく PASETO を選んだのは、v4.public には署名方式が 1 つしかないからです。JWT のように alg ヘッダーで方式を選ばないので、none を受け付けてしまう問題や、RSA と HMAC の鍵を取り違える問題がそもそも起きません。受け付けるのは v4.public. で始まるトークンだけです。
署名には暗黙のアサーション tenant-pass/v1/pass を使います。暗黙のアサーションとは、署名の計算には含めるものの、トークンには入れて送らない文字列のことです。これにより、同じ鍵で別の目的(別の製品や、tenant-pass の別のバージョン)のために署名したトークンは、パスとしては通りません。
フッターの鍵 ID は必須です。この ID でゲートウェイの鍵を選ぶので、鍵を入れ替えたり、複数のゲートウェイを動かしたりできます。フッターは署名で保護されていますが、署名を検証するまでは、鍵を選ぶ以外の用途には使いません。
クレームは aud、jti、iat、exp と、host か tenant のどちらか 1 つしか認めず、知らないクレームや重複したクレームがあれば拒否します。JSON のキーの重複は、言語によってパーサーの解釈が食い違う原因としてよく知られているからです。時刻は秒単位(小数なし)の RFC 3339 の UTC 形式で、同じ時刻の書き方が 1 通りに決まるようにしてあります。
有効期間は短く、exp - iat は長くても pass.max_ttl です(既定は 60 秒、上限は 5 分)。
ホスト名は、正規化済みのものしか受け付けません。小文字の ASCII で、ポートも末尾のドットも含まないもの以外は、すべて拒否します。tenant-pass の側では正規化しません。ゲートウェイと tenant-pass の 2 か所で正規化すると、結果が食い違うおそれがあるからです。
一方で、同じパスの再利用は止めません。リクエストの処理中にデータベースへ接続し直すこともあるので、有効期間内なら何度でも使えます。パスは 1 つのアプリケーションに結び付いていて数秒で切れるため、他人が盗んでも使えません。同じアプリケーションが使い回しても、すでに持っている以上のものは手に入りません。
パスワードとウィンドウ
tenant-pass は、状態を共有しない同じレプリカを何台も並べて動かします。同じアカウントについて 2 台が同時に問い合わせを受けたら、両方が同じパスワードを返さなければなりません。そのためにレプリカ同士で通信するのではなく、どのレプリカも同じ入力から同じパスワードを導出するようにしました。
password(n) = base64url(HMAC-SHA256(K, "tenant-pass/v1/password" || LP(store) || LP(node) || LP(account) || LP(key ID) || BE64(W) || BE64(n)))
nはウィンドウの番号です。ウィンドウは、同じパスワードを使う期間で、長さはrotation.window(既定は 15 分)です。ウィンドウの区切りは、(store, node, account) のハッシュから求めたオフセットの分だけアカウントごとにずらしてあるので、すべてのアカウントが同時にローテーションされることはありません。Kは、ウィンドウnが始まる時点で有効なローテーションキーです。鍵にはnot_before(使い始める時刻)があるので、新しい鍵を前もって配っておけば、すべてのレプリカが同じウィンドウで新しい鍵に切り替わります。- 出力は擬似乱数関数(PRF)による 256 ビットの値です。強さは、ランダムに作ったパスワードと変わりません。
正確な仕様とテストベクター(別に書いた Python の実装で作ったもの)は、パスワードの導出(tenant-pass/v1) にあります。
更新
ウィンドウ n の残り時間が rotation.renew_before(既定は 5 分)を下回ると、次のウィンドウ n+1 のパスワードを返し始めます。そのため、クライアントが受け取った認証情報は、受け取ってから少なくとも renew_before の間は使えます。いつ取り直せばよいかは、レスポンスの refresh_at(End(n) − renew_before)で伝えます。
ウィンドウを過去に戻さない
サーバーの時計はずれるものです。時計が少し遅れたレプリカがアカウントを古いウィンドウに戻してしまうと、新しいパスワードをすでに受け取ったクライアントが接続できなくなります。そこで tenant-pass は、アカウントをロックしたまま今の状態を読み、次のように判断します。
- すでに返すべきウィンドウになっている → そのパスワードをそのまま使います(書き込みません)。
- すでに次のウィンドウになっている(別のレプリカの時計が少し進んでいる)→ そのウィンドウのパスワードを返します。過去には戻しません。
- それより古い、状態がわからない、または tenant-pass が設定したものではない → 返すべきウィンドウのパスワードを書き込みます。
- 2 ウィンドウ以上先になっている → tenant-pass が設定したものではないと見なして上書きします。そのままにすると、ずっと先の未来まで有効なパスワードが残ってしまうからです。
最後の判断で上書きしても安全なのは、もう 1 段の防御があるからです。各レプリカは書き込む前に、自分の時計とストアのサーバーの時計を比べます(PostgreSQL では clock_timestamp()、MySQL では NOW(6)、Redis では TIME を、同じ往復の通信の中で読みます)。差が rotation.max_clock_skew(既定は 10 秒)を超えていれば、clock_skew として処理を拒否します。NTP の障害や、VM をスナップショットから戻したせいで時計が大きくずれたレプリカは、アカウントを未来にも過去にも動かせません。書き込めるのは、データベースと時刻が合っているレプリカだけです。
tenant-pass は、自分が設定した状態かどうかを、何も保存せずに見分けます。PostgreSQL では VALID UNTIL の値がウィンドウの境目の時刻と正確に一致するかどうかで、MySQL と Redis では、あり得るパスワードのハッシュを計算し直して比べることで判断します。
ストアごとの有効期限の扱い
期限で確実に無効になる: PostgreSQL
PostgreSQL のロールが持てるパスワードは 1 つだけです。tenant-pass はそこに PASSWORD '<SCRAM verifier>' VALID UNTIL '<End(n)>' を設定します。End(n) を過ぎれば、tenant-pass が止まっていても PostgreSQL 自身がそのパスワードを拒否します。開いている接続は、パスワードが変わっても期限が切れてもそのまま使えます。
ただしパスワードが 1 つしかないので、更新の期間に入って n から n+1 に切り替えた瞬間から、ウィンドウ n のパスワードでは新しい接続を開けなくなります。そのため、クライアントは認証に失敗したら(SQLSTATE 28P01)、認証情報を取り直して 1 回だけやり直す必要があります。クライアント連携 をご覧ください。
期限は tenant-pass が管理する: MySQL と Redis
MySQL と Redis には、パスワードの有効期限の機能がありません。代わりに、どちらもパスワードを 2 つ同時に持てます(MySQL はデュアルパスワード、Redis は ACL ユーザーの複数パスワード)。tenant-pass はこれを使い、アカウントに次のパスワードを持たせます。
- 通常の期間: ウィンドウ
nのパスワード - 更新の期間: ウィンドウ
nとn+1のパスワード
更新の期間には新旧どちらのパスワードも通るので、切り替えの最中に認証が失敗することはありません。古いパスワードは、バックグラウンドで動くスイーパーが消します。スイーパーは rotation.sweep_interval ごと(既定は 30 秒。間隔には少しばらつきを持たせます)に動き、ウィンドウが終わったパスワードと、tenant-pass が導出したものではないパスワードを削除します。1 ウィンドウの間まったく要求がなかったアカウントには、やがて使えるパスワードが 1 つも残らなくなります。
注意点として、パスワードは expires_at を過ぎても、長ければスイープの間隔 1 回分は使えてしまいます。tenant-pass が止まっていれば、止まっていた時間の分さらに延びます。
アカウントの安全性チェック
パスワードを書き換える以上、対象を間違えれば被害が出ます。そこで tenant-pass は、ディレクトリの情報を信じるだけでなく、ストアのアカウントそのものも、書き込みと同じロックの中で確かめます。
| 確認すること | PostgreSQL | MySQL | Redis |
|---|---|---|---|
名前が account_pattern(先頭から末尾まで一致させる正規表現)に一致する | ✓ | ✓ | ✓ |
| アカウントが存在する(tenant-pass はアカウントを作りません) | ✓ | ✓ | ✓ |
| 管理対象の目印が付いている | managed_group のメンバーである | managed_role が付与されている(それ以外のロールはない) | チャネルのルール &tenant-pass:managed を持つ |
| 管理者の権限を持たない | SUPERUSER、CREATEROLE、CREATEDB、REPLICATION、BYPASSRLS のどれも、直接にも、継承元のどのロールを通じても持たない | アカウントと管理対象のロールのどちらも、グローバル権限(*_priv の列すべて)、動的権限、mysql・sys・performance_schema への権限、PROXY を持たない | ACL DRYRUN で試し、どのカテゴリやセレクターを通じても管理用のコマンド(ACL、CONFIG、MODULE、FLUSHALL、FUNCTION LOAD など)を実行できないことを確かめる |
| ほかのテナントのデータに触れられない | 定義済みの pg_* ロール(例: pg_read_all_data)やログインできるロールのメンバーではなく、ADMIN OPTION も持たない | (グローバル権限を拒否しているので確認済み) | ACL DRYRUN で試し、ランダムなキーやチャネル(~*、&*)に触れられないことを確かめる |
| tenant-pass 自身の管理者アカウントではない | ✓ | ✓ | (目印で区別する) |
チェックに通らなかったときは、クライアントに 403 denied を返し、監査ログに account_refused を記録します。このエラーにはアラートを設定してください。
管理者の権限とサーバー全体に及ぶ権限については、あえて厳しくしています。スーパーユーザーに相当する属性、定義済みのロール、グローバル権限、ワイルドカードを使ったデータベースの権限、範囲を限らないキーのパターンのように、アカウント自身のオブジェクトを超えてサーバー単位で効く権限は、中身を見ずに拒否します。
MySQL のシステムスキーマは mysql、sys、performance_schema、information_schema です。Redis では、キーとチャネルのパターンは、ワイルドカードを含まない固定の文字列(プレフィックス)で始まる必要があります。
逆に、このチェックで一般には見つけられないものがあります。ほかのテナントのオブジェクトに対する個別の権限です(たとえば GRANT SELECT ON acme.invoices TO t_beta や、共有のテーブルを持つ NOLOGIN のグループ)。各テナントの権限をそのテナント自身のデータベース、スキーマ、テーブル、キーのプレフィックスに限るのは、プロビジョニングを行う側の仕事です。tenant-pass はそれができている前提で動きます。
状態を持たないことと、レプリカ
レプリカ同士でそろえる必要があるのは設定(ローテーションキー、window、renew_before、ノード名)だけで、ほかに共有するものはありません。
1 つのアカウントに同時に来た処理は、ストアのロック(PostgreSQL の pg_advisory_xact_lock、MySQL の GET_LOCK)とプロセス内のロックで、1 つずつ順番に片付けます。Redis への書き込みは、何度やっても結果が同じになるように作ってあります。
テナントの数のレート制限と repair のレート制限は、レプリカごとに数えます。レプリカが N 台あれば、全体での上限は最大で N 倍になります。レプリカを再起動しても、失われるものはありません。
tenant-pass がしないこと
アカウントの作成、削除、権限の付与は一切しません。テナントとそのアカウントは利用者が用意するもので、それも tenant-pass とは別の、より強い権限を持つ処理で行うべきです。
データベースへの通信をプロキシすることはありません。エンドユーザーの認証もしません(それはゲートウェイの仕事です)。認証情報をどこかに保存することもありません。
この文書は、リポジトリにある docs/ja/design.md をもとにしています(英語版は docs/en/design.md)。