tenant-pass

クライアント連携

アプリケーションが認証情報を取得し、保持し、更新する方法を説明します。キャッシュはメモリ内だけに置くこと、refresh_at での更新、認証に失敗したときに repair を付けて 1 回だけやり直すこと、接続プールの扱い、Go での例を扱います。

このページの見出し
  1. リクエストごとの流れ
  2. 認証情報はメモリの外に出さない
  3. いつ更新するか
  4. 認証に失敗したとき
  5. すでに開いている接続
  6. リクエストの外で動く処理
  7. 接続プール
  8. PgBouncer とプロキシ
  9. Go での例

アプリケーションは、リクエストに付いてきたパスを tenant-pass に渡して、そのテナントのデータベースの認証情報を受け取ります。受け取った認証情報はプロセスのメモリにしばらく持っておき、期限が近づいたら取り直します。やることはこれだけですが、キャッシュの置き場所と、認証に失敗したときのやり直し方には決まりがあります。

リクエストごとの流れ

  1. ゲートウェイがパスを入れるヘッダー(たとえば Tenant-Pass)からパスを読みます。パスがないリクエストには、テナントのデータを読み書きする権限がありません。エラーにして返してください。
  2. そのテナントの認証情報を、プロセスのメモリ内のキャッシュから探します。見つかって、しかも now < refresh_at なら、それを使います。
  3. 見つからなければ、パスを付けて POST /v1/credentials を呼びます。返ってきた認証情報は refresh_at までキャッシュします。
  4. 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 回だけ取り直します。認証の失敗は、次のエラーで見分けます。

データベースエラー
PostgreSQLSQLSTATE 28P01(invalid_password)
MySQLエラー 1045(ER_ACCESS_DENIED_ERROR)
RedisWRONGPASS

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)。