アプリケーションは、リクエストに付いてきたパスを tenant-pass に渡して、そのテナントのデータベースの認証情報を受け取ります。受け取った認証情報はプロセスのメモリにしばらく持っておき、期限が近づいたら取り直します。やることはこれだけですが、キャッシュの置き場所と、認証に失敗したときのやり直し方には決まりがあります。
リクエストごとの流れ
- ゲートウェイがパスを入れるヘッダー(たとえば
Tenant-Pass)からパスを読みます。パスがないリクエストには、テナントのデータを読み書きする権限がありません。エラーにして返してください。 - そのテナントの認証情報を、プロセスのメモリ内のキャッシュから探します。見つかって、しかも
now < refresh_atなら、それを使います。 - 見つからなければ、パスを付けて
POST /v1/credentialsを呼びます。返ってきた認証情報はrefresh_atまでキャッシュします。 usernameとpasswordでhost:port(とdatabase)に接続します。
credential = cache.get(tenant)
if credential is None or now >= credential.refresh_at:
credential = tenant_pass.fetch(pass) # POST /v1/credentials
cache.put(tenant, credential)
conn = connect(credential)
if conn failed with authentication error:
credential = tenant_pass.fetch(pass, repair=True)
cache.put(tenant, credential)
conn = connect(credential) # やり直すのは 1 回だけ。繰り返さない
キャッシュのキーは、パスから求めたテナント(またはレスポンスの tenant フィールド)にします。キャッシュを複数のプロセスやホストで共有してはいけません。
認証情報はメモリの外に出さない
認証情報を書き込んでよいのは、そのプロセスのメモリだけです。ディスク、共有キャッシュ(Redis、memcached)、データベース、ログ、エラーレポート、メトリクスのラベルには決して書かないでください。アプリトークンも同じで、コードに埋め込まず、プロセスの環境変数かシークレットとしてマウントしたファイルで渡します。
いつ更新するか
refresh_at になったら取り直します。この時刻から、tenant-pass は次のウィンドウのパスワードを返すようになります。早めに取り直すのはかまいません。その場合は同じパスワードが返ってきます。
PostgreSQL では、refresh_at を実質的な有効期限と考えてください。どれか 1 つのクライアントが更新した時点で、前のパスワードでは新しい接続を開けなくなるからです。MySQL と Redis はもう少し余裕があり、前のパスワードも expires_at まで使えます(さらに、古いパスワードを消す処理であるスイープの間隔 1 回分まで残ります)。
認証に失敗したとき
データベースがパスワードを拒んだら、"repair": true を付けて認証情報を 1 回だけ取り直します。認証の失敗は、次のエラーで見分けます。
| データベース | エラー |
|---|---|
| PostgreSQL | SQLSTATE 28P01(invalid_password) |
| MySQL | エラー 1045(ER_ACCESS_DENIED_ERROR) |
| Redis | WRONGPASS |
PostgreSQL のロールはパスワードを 1 つしか持てないので、ローテーションの直後にこのエラーが出るのは想定どおりです。MySQL と Redis では、めったに起きないはずです。どちらの場合も、やり直しは 1 回で止めてください。
tenant-pass 自体がエラーを返したときは、ステータスで対応を分けます。400・401・403 は、同じパスで何度やっても成功しません。ログに残して、リクエストをエラーにします。429 と 503 は一時的なものなので、間隔を空ければやり直せます。
なお、tenant-pass の TLS 証明書の検証は無効にしないでください。
すでに開いている接続
パスワードが変わっても、期限が切れても、開いている接続は閉じられません。接続プールはそのまま持ち続けてかまいません。今のパスワードが要るのは、これから開く接続だけです。
リクエストの外で動く処理
認証情報を取り直すには、新しいパスかジョブチケットが要ります。つまり、パスを持つリクエストを処理している間でなければ更新できません。リクエストが終わった後で動く処理は、パスの代わりにジョブチケットを使い、{"pass": …} の代わりに {"ticket": …} を送ります。チケットは、リクエストの処理中に POST /v1/job-tickets で発行する(この機能を有効にしている場合)か、スケジューラーから受け取ります。詳しくは バックグラウンドジョブ(ジョブチケット) をご覧ください。
接続プール
プールはテナントごとに分けます。テナント A のプールの接続を、テナント B のリクエストに使ってはいけません。
キャッシュした認証情報を更新したら、プールがこの後に開く接続には新しいパスワードを使わせます。作るときにパスワードを 1 度だけ受け取るプールなら、作り直しが必要です。接続のたびにパスワードを渡せる仕組み(pgx の BeforeConnect や lib/pq のコネクターなど)を使えば、作り直さずに済みます。
しばらく使われていないテナントのプールは閉じて、その認証情報がメモリに残らないようにしてください。
PgBouncer とプロキシ
設定の connect には、PgBouncer などのコネクションプーラーを指定してください。詳しくは PostgreSQL › PgBouncer をご覧ください。
Go での例
type cred struct {
Username, Password, Host, Database string
Port int
RefreshAt time.Time `json:"refresh_at"`
}
func fetch(ctx context.Context, hc *http.Client, token, pass string, repair bool) (*cred, error) {
body, _ := json.Marshal(map[string]any{"pass": pass, "repair": repair})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://tenant-pass.internal:8443/v1/credentials", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tenant-pass: %s", resp.Status)
}
var c cred
if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&c); err != nil {
return nil, err
}
return &c, nil
}この文書は、リポジトリにある docs/ja/client-integration.md をもとにしています(英語版は docs/en/client-integration.md)。