01Architecture
仕組み
tenant-pass は、共有する状態を持たない小さなサーバーです。仕事は、パスを検証し、テナントを調べ、鍵からパスワードを計算してデータベースに設定することだけです。どの段階も、判断に迷ったら処理を止めます(フェイルクローズ)。
Components
部品と、それぞれが持つ鍵
ゲートウェイの秘密鍵とローテーションキーは、決して同じ場所に置きません。ゲートウェイはパスに署名できますが、パスワードは計算できません。tenant-pass はパスワードを計算できますが、パスには署名できません。アプリケーションは、どちらの鍵も持ちません。
ゲートウェイ
付属の tenant-pass-gateway、または利用者が用意したもの
持つものEd25519 の秘密鍵
エンドユーザーのセッションを、そのテナントについて認証します。そのうえで、テナントと転送先のアプリケーションを書いた短命のパスに署名します。
アプリケーション
利用者のもの(信頼しない)
持つものアプリトークンかクライアント証明書(両方でも可)
リクエストとパスを受け取り、パスを認証情報に交換して、テナントのデータベースに接続します。
tenant-pass
このソフトウェア(信頼する)
持つものゲートウェイの公開鍵、アプリトークンのハッシュ、ローテーションキー、ストアの管理者の認証情報
アプリケーションとパスを検証し、テナントを特定して、パスワードを設定してから返します。
ディレクトリ
設定ファイル、または利用者の PostgreSQL / MySQL
持つものテナントごとの接続先ノード、アカウント、有効か無効か、許可するアプリケーション
どのアカウントがどのテナントのものかを決める、信頼できる情報源です。
ストア
PostgreSQL・MySQL・Redis / Valkey
持つものテナントのアカウント
tenant-pass が設定したパスワードと期限に従って、ログインを許可または拒否します。
ジョブ発行者(任意)
利用者のスケジューラーなど
持つもの発行者の Ed25519 秘密鍵
どのリクエストからも始まらない処理のために、ジョブチケットに署名します。
KMS(任意)
HashiCorp Vault / OpenBao の Transit
持つものローテーションキーと署名鍵
HMAC と署名を KMS の中で計算します。鍵は外に出ません。
Request pipeline
1 回のリクエストで、
確かめること
POST /v1/credentials は、右の順に検査します。どこかで失敗すれば、その先には進みません。クライアントに返すのは決まったエラーコードだけで、理由(pass_expired、account_refused など)は監査ログに書きます。
理由を返さないのは、攻撃者に「どこまで通ったか」を教えないためです。パスワード、ベリファイア、ハッシュ、パス、ジョブチケット、アプリトークン、ローテーションキーは、ログにも決して書きません。
- 1
パス・メソッド・Content-Type
メソッドが POST で、Content-Type が application/json であることを確かめます。
失敗 → 404 not_found / 405 method_not_allowed / 415 unsupported_media_type
- 2
同時実行数
重い処理に入る前に、同時に処理しているリクエスト数が上限(既定 256)に達していないかを確かめます。
失敗 → 503 busy
- 3
アプリケーションの認証
Bearer トークンの SHA-256 ハッシュ、クライアント証明書の URI SAN、またはその両方で、呼び出したアプリケーションを確かめます。
失敗 → 401 unauthorized
- 4
リクエストボディを厳密に読む
本文は 4096 バイトまでです。未知のフィールド、重複したフィールド、後ろに続く余分なデータは拒否します。pass と ticket は、どちらか一方だけを指定します。
失敗 → 413 request_too_large / 400 invalid_request
- 5
パスの検証
kid で検証に使う鍵を選び、暗黙のアサーション(後述)も含めて署名を検証します。続いて、クレームの形式と有効期間を確かめ、aud が呼び出したアプリケーションの名前であることを確かめます。
失敗 → 400 invalid_pass
- 6
異なるテナント数の制限
直近 1 分間に、このアプリケーションが求めた異なるテナント(ホスト)の数を数えます。すでに求めたテナントを繰り返し求めても、数には入りません。
失敗 → 429 rate_limited
- 7
ディレクトリでテナントを特定
host または tenant と、アプリケーション名で調べます。テナントがない、無効になっている、そのアプリケーションに許可されていない場合は denied です。
失敗 → 403 denied / 503 unavailable
- 8
ストアで発行
ロックを取る → アカウントの安全性を確かめる → 時計を比べる → 現在の状態を読む → 前にしか進まない計画を立てる → 必要なら書き込む、の順に進みます。
失敗 → 403 denied / 503 unavailable
- ✓
認証情報を返し、監査ログを 1 行書く
すべての応答に Cache-Control: no-store を付けます。途中のプロキシやクライアントに、パスワードが残らないようにするためです。
The pass
パスの中身
パスは PASETO の v4.public トークン(Ed25519 の署名)です。下の例は、仕様に載っているテストベクターそのものです。仕様どおりに署名する実装なら、同じ入力からまったく同じバイト列を作ります。
v4.public.eyJhdWQiOiJiaWxsaW5nIiwiaG9zdCI6ImFjbWUuZXhhbXBsZS5jb20iLCJpYXQiOiIyMDI2LTExLTAzVDEyOjAwOjAwWiIsImV4cCI6IjIwMjYtMTEtMDNUMTI6MDA6MzBaIiwianRpIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9lGRulfEQdJnx_Iagf4Fp94_8eSIifNPHT82HGA8KqWhSX_KikCQafPjF2Ntupw3XnYtRoZ6xe1-uf46uZcHoDQ.eyJraWQiOiJndzEifQ
ヘッダー
v4.public だけを受け付けます。アルゴリズムは 1 つに決まっていて、JWT の alg のように選ぶ余地はありません。
メッセージ(クレーム)
{"aud":"billing","host":"acme.example.com","iat":"2026-11-03T12:00:00Z","exp":"2026-11-03T12:00:30Z","jti":"AAAAAAAAAAAAAAAAAAAAAA"}
署名(64 バイト)
ヘッダー、メッセージ、フッター、暗黙のアサーションをまとめて、Ed25519 で署名したものです。
フッター
{"kid":"gw1"} だけを書きます。どのゲートウェイの鍵で検証するかを選ぶために使い、署名の対象にも含まれます。
| クレーム | 必須 | 意味 |
|---|---|---|
| aud | はい | 転送先のアプリケーション名。tenant-pass の設定の apps[].name と一致する必要があります。 |
| host | host / tenant のどちらか一方 | 正規化したホスト名(小文字、ポートなし、末尾のドットなし、国際化ドメイン名は xn-- 形式)。ディレクトリの by_host でテナントを調べます。 |
| tenant | host / tenant のどちらか一方 | ゲートウェイがすでに知っているテナント ID。ディレクトリの by_tenant でテナントを調べます。tenant-pass-gateway は常にこちらを使います。 |
| iat | はい | 発行時刻。UTC で秒単位、末尾に Z を付けた YYYY-MM-DDTHH:MM:SSZ の形式です。 |
| exp | はい | 有効期限。形式は iat と同じです。 |
| jti | はい | ランダムな識別子(16〜128 文字、128 ビット以上を推奨)。監査ログに出るので、ゲートウェイのログと突き合わせられます。 |
これ以外のクレームや、同じキーが 2 回出てくる JSON は拒否します(JSON のキーの重複は、言語によってパーサーの解釈が食い違う原因としてよく知られています)。全体の大きさは 2048 バイトまでです。
時刻の決まり
- exp > iat
- exp − iat ≤ pass.max_ttl (既定 60 秒・上限 300 秒)
- now < exp (期限には猶予なし)
- iat ≤ now + leeway (時計のずれの許容。既定 5 秒)
推奨は exp = iat + 30 秒です。
同じパスを再利用されたら?
拒みません。期限内なら、同じパスを何度でも使えます。再接続のときに取り直さずに済むようにするためです。それで困らないのは、パスが 1 つのアプリケーション宛てで、数秒で切れるからです。ほかのアプリケーションは使えませんし、同じアプリケーションが使い回しても、すでに持っている以上のものは得られません。
ホスト名の正規化の決まり、検証の順序、Go と PHP で署名する例は、パスの形式 にあります。
Stateless derivation
パスワードは「保存」せず「計算」する
ローテーションキー K tenant-pass は、共有する状態を持たない同じサーバー(レプリカ)を何台も並べて動かします。同じアカウントについて同時に問い合わせを受けた 2 台は、同じパスワードを返さなければなりません。
そこで、データベースやキャッシュを通じて情報を合わせる代わりに、すべてのレプリカが同じ入力から同じパスワードを計算します。入力は、ストアの種類、ノード名、アカウント名、鍵 ID、ウィンドウの長さ、ウィンドウ番号です。計算には HMAC-SHA256 を使い、出力は 256 ビット(base64url で 43 文字)です。ランダムに作ったパスワードと同じ強さがあります。
そのため、盗まれるパスワードの一覧はどこにもありません。その代わり、ローテーションキーを持つ者は誰でも、すべてのパスワードを計算できます。鍵は tenant-pass のシークレットにだけ置き、できれば KMS に入れてください。
# LP(x) = 長さ(4 バイト)+ x、BE64(n) = 64 ビットの整数 A = LP(store) || LP(node) || LP(account) offset = uint64(SHA-256("tenant-pass/v1/offset" || A)[0:8]) mod W n(t) = floor((unix(t) − offset) / W) password(n) = base64url( HMAC-SHA256(K, "tenant-pass/v1/password" || A || LP(id) || BE64(W) || BE64(n)) ) salt(n) = HMAC-SHA256(K, "tenant-pass/v1/salt" || A || LP(id) || BE64(W) || BE64(n))
- 入力ごとに長さを前に付けるので、区切りがずれて、別の入力の組と同じ値になることはありません。
- ラベルを分ける(password・salt・offset)ので、同じ鍵でも用途ごとに無関係な値になります。
- ストアの種類も入力なので、PostgreSQL と Redis の同名のアカウントでもパスワードは無関係です。
- ウィンドウの長さ W も入力なので、設定を変えても過去のパスワードが生き返りません。
- offset(アカウントごとにローテーションの時刻をずらす値)によって、すべてのアカウントが同時にローテーションするのを避けます。offset は鍵に依存しません。
テストベクターは、これとは別に書いた Python の実装で作っています。詳しくは パスワードの導出 をご覧ください。
Windows & rotation
15 分ごとに入れ替わり、期限はデータベースが守る
時間を「ウィンドウ」(既定 15 分)に区切り、ウィンドウごとに別のパスワードを使います。ウィンドウの残りが renew_before(既定 5 分)より短くなると、tenant-pass は次のウィンドウのパスワードを返し始めます。そのため、受け取った認証情報は少なくとも 5 分は使えます。
いまのウィンドウ 次のウィンドウ PostgreSQL
期限はデータベースが強制
PASSWORD 'SCRAM…' VALID UNTIL End(n) を設定します。End(n) を過ぎれば、tenant-pass が止まっていても PostgreSQL 自身がログインを拒否します。ロールのパスワードは 1 つしか持てないので、次のウィンドウに切り替えた時点で、古いパスワードでは新しく接続できなくなります。クライアントは認証エラー(28P01)を受けたら、認証情報を取り直して 1 回だけリトライしてください。
MySQL・Redis / Valkey
2 つのパスワードを持ち、期限切れを消す
どちらもパスワードの期限を持ちませんが、2 つを同時に持てます(MySQL のデュアルパスワード、Redis の ACL の複数パスワード)。切り替えの間は、新旧どちらのパスワードも使えます。バックグラウンドで動く削除の処理(スイーパー、既定 30 秒ごと)が、ウィンドウの終わったパスワードと、tenant-pass が導出していないパスワードを消します。
既存の接続
開いている接続は切れない
パスワードの変更や期限切れで、すでに開いている接続が閉じられることはありません。接続プールはそのまま使えます。新しいパスワードが要るのは、新しく接続するときだけです。裏を返せば、侵害が疑われるときに接続を切るには、別の操作が要ります(運用)。
ウィンドウは前にしか進めない
時計はずれるものです。時計が少し遅れたレプリカが、アカウントを古いウィンドウに戻してしまうと、新しいパスワードを受け取ったクライアントが接続できなくなります。これを防ぐため、tenant-pass はロックを取った状態でアカウントの今の状態を読み、次の決まりで動きます。
- =すでに目標のウィンドウ → そのまま使います(書き込みません)
- +1すでに次のウィンドウ(別のレプリカの時計が少し進んでいる) → そちらを返します。決して戻しません
- −古い・不明・管理されていない → 目標のウィンドウを書き込みます
- ≥22 ウィンドウ以上先 → 管理されていないとみなして上書きします(放っておくと、遠い未来まで有効なパスワードが残るためです)
tenant-pass は、自分が設定した状態かどうかを、何も保存せずに見分けます。PostgreSQL では VALID UNTIL がウィンドウの境目とぴったり一致するかで、MySQL と Redis では候補のハッシュを計算し直して比べることで判断します。
Clock check
書く前に、データベースの時計と比べる
「2 ウィンドウ以上先なら上書き」が安全なのは、もう 1 つの守りがあるからです。書き込みの前に、各レプリカは自分の時計とストアのサーバーの時計を、同じ往復の中で比べます。差が rotation.max_clock_skew(既定 10 秒)を超えれば、clock_skew で拒否します。
PostgreSQL
clock_timestamp()
MySQL
NOW(6)
Redis
TIME
NTP の障害や、VM のスナップショットからの復元で時計が飛んだレプリカは、アカウントを未来にも過去にも動かせません。ただし、データベースのサーバーの時計が狂っていれば、tenant-pass はそれに従います。データベースのサーバーでも NTP で時計を合わせ、clock_skew の発生を監視してください。
鍵の入れ替えは、全レプリカが同じウィンドウで行う
ローテーションキーには使い始める時刻 not_before があります。ウィンドウ n では、not_before ≤ Start(n) を満たす鍵のうち最も新しいものを使います。新しい鍵を前もって全レプリカに配っておけば、レプリカ同士で連絡を取り合わなくても、全レプリカが同じウィンドウで切り替わります。
STEP 1
鍵を作る
tenant-pass gen-rotation-key -out rotation-2026-12.key
STEP 2
未来の not_before を付けて追加する
全レプリカへの配布が終わるより十分あとの時刻(例: 翌日 03:00 UTC)を指定して、全レプリカに配ります
STEP 3
自然に切り替わる
not_before 以降に始まるウィンドウから、新しい鍵が使われます。ほかには何も起きません
STEP 4
古い鍵を外す
not_before + window + renew_before を過ぎたら、古い鍵を設定から削除して、配り直します
Per-store behaviour
データベースに送るのは、ハッシュだけ
tenant-pass は、パスワードのハッシュ(ベリファイア)を自分で計算して送ります。平文のパスワードが、データベースのサーバー、ログ、SQL 文の統計、レプリケーションに届くことはありません。平文が存在するのは、tenant-pass のメモリと API の応答の中だけです。
PostgreSQL 16 以降
-- pg_advisory_xact_lock の中で ALTER ROLE "t_acme" PASSWORD 'SCRAM-SHA-256$4096:…' VALID UNTIL '2026-11-03 12:13:59+00';
16 より前では、CREATEROLE を持つロールが、スーパーユーザー以外のどのロールのパスワードでも変えられます。また、あるテナントがほかのテナントの検査を妨げることもできてしまいます。そのため 16 以降を必須にしています。テナントが接続できない、tenant-pass 専用のデータベースも必要です。
MySQL 8.0.27 以降・8.4・9.x
-- GET_LOCK の中で ALTER USER 't_acme'@'%' IDENTIFIED WITH caching_sha2_password AS 0x2441… RETAIN CURRENT PASSWORD; ALTER USER 't_acme'@'%' DISCARD OLD PASSWORD;
ハッシュ(20 バイトのソルト、5000 ラウンドの SHA-crypt)を 16 進で送ります。MariaDB には対応していません(認証の方式が違い、デュアルパスワードの機能がないためです)。
Redis / Valkey 7.0 以降 / 7.2 以降
ACL SETUSER t_acme resetpass #<sha256 of password(n)> [#<sha256 of password(n+1)>]
Redis は ACL をレプリカに複製しません。そのため、プライマリ、レプリカ、クラスターの各ノードをすべて設定に登録し、同じパスワードの組を全サーバーに書き込みます。resetpass はパスワードだけを置き換え、権限やキーの範囲は変えません。
Account safety checks
間違ったアカウントのパスワードは変えない
ディレクトリが誤って管理者のアカウントを指していたら、tenant-pass は管理者のパスワードを書き換えてしまいます。それを防ぐため、ディレクトリとは別に、書き込みと同じロックの中でアカウントそのものを検査します。条件を満たさなければ 403 denied(監査ログでは account_refused)を返します。これが起きたときは、設定の誤りか攻撃の疑いがあります。アラートを出すようにしてください。
| 検査 | PostgreSQL | MySQL | Redis |
|---|---|---|---|
| アカウント名が account_pattern(先頭から末尾まで一致を求める正規表現)に一致する | ✓ | ✓ | ✓ |
| アカウントが存在する(tenant-pass はアカウントを作らない) | ✓ | ✓ | ✓ |
| 「管理対象」の印がある | managed_group のメンバーである | managed_role が付与され、ほかのロールはない | チャネルルール &tenant-pass:managed を持つ |
| 管理者権限がない | SUPERUSER・CREATEROLE・CREATEDB・REPLICATION・BYPASSRLS を、直接にも祖先のロールを通じても持たない | グローバル権限・動的権限・システムスキーマへの権限・PROXY がない(アカウントと管理対象ロールの両方) | ACL DRYRUN で、ACL・CONFIG・MODULE・FLUSHALL などを実行できないことを確かめる |
| テナントをまたぐアクセスがない | 定義済みの pg_* ロールやログインロールのメンバーではなく、ADMIN OPTION も持たない | (グローバル権限を持つアカウントを拒否することで防ぐ) | ACL DRYRUN で、ランダムなキーやチャネルに触れられないことを確かめる |
| tenant-pass 自身の管理者アカウントではない | ✓ | ✓ | (管理対象の印で区別する) |
Job tickets
後から動く処理には、ジョブチケット
パスは 1 つのリクエストに結び付くので、有効期間は数秒です。キューに入れた処理や夜間のバッチのために、パスの有効期間を延ばすことはしません。代わりに、1 つのテナントと 1 つのアプリケーションに限ったジョブチケットを使います。
1 テナント × 1 アプリ | パス | ジョブチケット | |
|---|---|---|
| 署名する者 | ゲートウェイ | tenant-pass 自身、または利用者が運用する信頼された発行者(スケジューラー) |
| 書かれるもの | ホストまたはテナント | 常にテナント ID |
| 有効期間 | 数秒(pass.max_ttl。5 分以下) | 数分〜数時間(job_tickets.max_ttl。既定 1 時間、上限 24 時間) |
| 暗黙のアサーション | tenant-pass/v1/pass | tenant-pass/v1/job |
| 交換先 | POST /v1/credentials {"pass"} | POST /v1/credentials {"ticket"} |
| レート制限 | limits.max_tenants_per_minute | apps[].jobs.max_tenants_per_minute(パスとは別に数える) |
入手のしかた 1
リクエストの続き: POST /v1/job-tickets
ユーザーのリクエスト(新しいパス付き)を処理している間に、パスをチケットに交換してジョブに渡します。エクスポートやインポートなど、ユーザーが始めた処理に向いています。アプリケーションごとに "jobs": {"issue": true} で有効にします。1 つのパスと交換できるチケットは、レプリカごとに 1 枚です。チケットの発行も、認証情報と同じ「1 分あたりのテナント数」の制限に数えます。
入手のしかた 2
決まった時刻の処理: 信頼された発行者
夜間のレポートや課金など、どのリクエストからも始まらない処理では、利用者が運用するスケジューラーが、自分の鍵でチケットに署名します。発行者はアプリケーションから切り離して置きます。アプリケーションがチケットに署名できたら、tenant-pass を置く意味がなくなるからです。
発行済みのチケットを 1 枚ずつ取り消す仕組み(失効リスト)はありません。止めるには、発行者の鍵を設定から外すか、ディレクトリでテナントを無効にします。詳しくは ジョブチケット をご覧ください。
tenant-pass-gateway
パスに署名する判断は、ゲートウェイの仕事
tenant-pass の安全性は、「どのリクエストにパスを渡すか」というゲートウェイの判断にかかっています。付属の tenant-pass-gateway は、Caddy・nginx・Traefik・Envoy の forward-auth(転送の前に認可を問い合わせる仕組み)として組み込む小さなサービスです。
- ・ID プロバイダーが発行した JWT を、JWKS(公開鍵の一覧)で検証します。受け付けるのは RS256・ES256・EdDSA などの公開鍵方式だけで、HS* と none は決して受け付けません。
- ・トークンに書かれたテナントに、要求されたホストのテナントが含まれるときだけ、パスに署名します。
- ・プロキシは、クライアントが付けてきた Tenant-Pass ヘッダーを、必ず取り除いてから転送します。
- ・JWKS を 1 時間更新できなければ、すべてのトークンを拒否します(フェイルクローズ)。
- ・forward_claims を使うと、セッションのトークンはアプリケーションに渡さず、利用者 ID だけを渡せます。
| 状況 | 応答 | パス |
|---|---|---|
| 有効なセッションがあり、ホストのテナントがトークンのテナントに含まれる | 200 | あり |
| 別のテナントに対する有効なセッション | 403 | なし(プロキシが拒否) |
| セッションがない・無効・期限切れ | 200 / 401 | なし(既定の pass_through では転送は続くので、ログイン画面は表示できる) |
| どのルートにも一致しないホスト | 200 | なし |
| Host ヘッダーの形式が正しくない、またはあいまい | 400 | なし |
| 呼び出し元の認証(共有シークレット)が違う | 401 | なし |
セッションのないリクエストもアプリケーションに届きますが、パスがないので、テナントの認証情報は取れません。独自のゲートウェイを使う場合の決まりは ゲートウェイ連携 にあります。
Key management
鍵をホストから持ち出させない
HMAC と署名は KMS の中で計算。鍵は外に出ない 鍵がファイルとして置かれていると、tenant-pass のホストに侵入した攻撃者はローテーションキーをコピーできます。そうなると、追い出されたあとも、鍵が有効な間はオフラインで全テナントのパスワードを計算できます。
HashiCorp Vault / OpenBao の Transit を使えば、鍵は Vault の外に出ません。攻撃者が鍵を使えるのは、ホストとその Vault トークンを握っている間だけです。トークンを失効させれば何も計算できなくなり、鍵を使った記録も Vault の監査ログに残ります。ただし、いまホストを握っている攻撃者からは守れません。
| 鍵 | 使う者 | Transit の種類 |
|---|---|---|
| ローテーションキー(パスワードの導出) | tenant-pass | hmac |
| ジョブチケットの署名鍵 | tenant-pass | ed25519 |
| ゲートウェイの署名鍵 | tenant-pass-gateway | ed25519 |
- ・Transit の HMAC は通常の HMAC-SHA256 なので、導出の仕様もテストベクターも変わりません。
- ・使う鍵のバージョンを固定し、ほかのバージョンによる応答は拒否します。
- ・Transit が署名したトークンは、起動時に読み込んだ公開鍵で、tenant-pass があらためて検証します。
- ・導出した結果はアカウントとウィンドウごとにキャッシュするので、Transit を呼ぶのは、1 つのアカウントの 1 ウィンドウあたりおおむね 2 回です。