仕組み
テナント 1 つに、LOGIN ロールが 1 つ対応します。ウィンドウ n のパスワードを設定するとき、tenant-pass は pg_advisory_xact_lock でロックを取ったトランザクションの中で、次の 1 文を実行します。
ALTER ROLE "t_acme" PASSWORD 'SCRAM-SHA-256$4096:…' VALID UNTIL '2026-11-03 12:13:59+00';
サーバーに送るのは SCRAM-SHA-256 のベリファイア(パスワードから計算した検証用の値)だけで、平文のパスワードは届きません。VALID UNTIL の期限は PostgreSQL 自身が守らせるので、期限を過ぎれば、tenant-pass が止まっていてもそのパスワードでは新しい接続を開けません。開いている接続は、パスワードが変わっても期限が切れても影響を受けません。
気を付けたいのは、ロールが持てるパスワードが 1 つだけだという点です。tenant-pass が次のウィンドウに切り替えると、前のパスワードは、その expires_at より前であっても新しい接続には使えなくなります。切り替えは refresh_at の時刻に起きます(別のレプリカの時計が少し進んでいれば、数秒早まることもあります)。そのため PostgreSQL では、refresh_at を実質的な有効期限として扱い、28P01 のエラーが出たら 1 回だけやり直してください(クライアント連携)。
対応するバージョン
PostgreSQL 16 以降が必要です。tenant-pass は動作中もバージョンを確かめます。
16 が必要なのは、このバージョンで CREATEROLE の仕様が変わり、管理者が ADMIN OPTION を持つロールにしか効かなくなったからです。16 より前では、CREATEROLE を持つロールはスーパーユーザー以外のどのロールのパスワードでも変えられます。さらに、ロールが自分へのメンバーシップをほかのロールに与えられるので、あるテナントが別のテナントのロールを、tenant-pass のメンバーシップのチェックに通らない状態にできてしまいます。
password_encryption は scram-sha-256(PostgreSQL 14 以降の既定値)にし、pg_hba.conf でもテナントのロールには scram-sha-256 を指定します。
準備(PostgreSQL 16 以降)
まず、スーパーユーザー(またはプロビジョニング用のロール)で次の SQL を実行します。tenant-pass 専用のデータベース、tenant-pass がログインするアカウント、管理対象の目印になるロールの 3 つを作ります。
-- tenant-pass 専用のデータベース。tenant-pass はロールごとの処理を
-- アドバイザリーロックで 1 つずつ行うが、このロックは 1 つのデータベースの中でしか効かない。
-- テナントがこのデータベースに接続できると、別のテナントのロックを取って
-- そのローテーションを止められてしまう。PUBLIC や管理対象のロールが
-- 接続できるデータベースでは、tenant-pass は動作を拒否する。
CREATE DATABASE tenant_pass;
REVOKE ALL ON DATABASE tenant_pass FROM PUBLIC;
-- tenant-pass がログインするアカウント
CREATE ROLE tenant_pass_admin LOGIN CREATEROLE PASSWORD '<random>';
GRANT CONNECT ON DATABASE tenant_pass TO tenant_pass_admin;
-- 目印: tenant-pass はこのロールのメンバーにしか手を出さない
CREATE ROLE tenant_pass_managed NOLOGIN;
テナントを追加するたびに、利用者のプロビジョニングの処理で次の SQL を実行します。
CREATE ROLE t_acme LOGIN IN ROLE tenant_pass_managed;
-- tenant-pass が t_acme のパスワードを変えられるようにする。ただし t_acme として振る舞うことはできない:
GRANT t_acme TO tenant_pass_admin WITH ADMIN OPTION, INHERIT FALSE, SET FALSE;
-- その後、テナントに必要な権限を t_acme に与える。例:
-- CREATE SCHEMA acme AUTHORIZATION t_acme;
tenant_pass_admin が自分でテナントのロールを作る場合は、PostgreSQL 16 以降なら ADMIN OPTION が自動で付きます。このとき INHERIT と SET が付くかどうかは createrole_self_grant で決まるので、この設定は空のままにしておいてください。
INHERIT FALSE, SET FALSE を付けておけば、tenant_pass_admin は普段はテナントの権限を使えません。ただし、これは破れない壁ではありません。ADMIN OPTION を持つ管理者は、そのロールを SET TRUE 付きで自分に与え直せるからです。tenant-pass がそうすることはありませんが、tenant_pass_admin でログインできる人にはできます。このアカウントは、それだけの力を持つものとして守ってください。
テナントのロールには SUPERUSER、CREATEROLE、CREATEDB、REPLICATION、BYPASSRLS を与えないでください。与えても、tenant-pass がそのロールを拒否するだけです。
tenant-pass からの接続は、トランザクション単位のコネクションプーラーを通さず、PostgreSQL に直接つなぎます。
サーバーでは ssl = on にし、tenant-pass が接続に使うアドレスと一致する SAN(証明書に書かれた名前)を持つ証明書を設定します。一致しない場合は、tenant-pass の tls.server_name で名前を指定します。CA の証明書は tls.ca_file に指定します。
安全性チェック
変更を加える前に、tenant-pass はアドバイザリーロックを取ったまま pg_roles と pg_auth_members を読み、次の条件を確かめます。1 つでも外れていれば変更を拒否し、監査ログに account_refused を残します。
- ロールの名前が
account_patternに一致する。 - ロールが存在し、
LOGINを持つ。 managed_groupのメンバーである(pg_has_role(role, group, 'MEMBER'))。SUPERUSER、CREATEROLE、CREATEDB、REPLICATION、BYPASSRLSのどれも持たない。- 直接にも間接にもメンバーになっているどのロールも、これらの属性を持たない。また、定義済みの
pg_*ロール(pg_read_all_data、pg_write_server_files、pg_execute_server_programなど)ではなく、ログインもできない。 - そのロール自身も、それがメンバーになっているどのロールも、どのロールに対しても
ADMIN OPTIONを持たない。 managed_groupそのものでも、tenant-pass 自身がログインするロールでもない。
ロールのほかにも確かめることがあります。自分の時計をサーバーの clock_timestamp() と比べ、差が rotation.max_clock_skew を超えていれば書き込みません。また、サーバーが PostgreSQL 16 以降であることと、管理者の接続先のデータベースに PUBLIC もどの管理対象のロールも接続できないことの 2 つがそろわない限り、何の処理もしません。データベースの分離は、レディネスの確認と check-config -connect のときに確かめるほか、少なくとも 5 分ごとに確かめ直します。後から誰かが GRANT CONNECT しても、これで気付けます。
テナントのロールに与えるのは、そのテナント自身のオブジェクトへの権限だけにしてください(特別な属性を持たない NOLOGIN のグループロールを通して与えてもかまいません)。それより広い権限があると、tenant-pass はそのロールを拒否します。
接続の枯渇
テナントはサーバーの max_connections を共有しています。1 つのテナントがそれを使い切れると、tenant-pass もパスワードをローテーションできなくなり、やがてすべてのテナントのパスワードが期限切れになります。ロールごとの上限(ALTER ROLE t_acme CONNECTION LIMIT 20)を付け、管理者のための接続を確保しておいてください。PostgreSQL 16 以降なら、reserved_connections = 5 と GRANT pg_use_reserved_connections TO tenant_pass_admin で確保できます。
ノードの設定
{
"name": "pg1",
"type": "postgres",
"endpoint": {
"address": "pg1.internal:5432",
"user": "tenant_pass_admin",
"password_file": "/run/secrets/pg1-admin-password",
"database": "tenant_pass",
"tls": {"ca_file": "/etc/tenant-pass/db-ca.pem"}
},
"managed_group": "tenant_pass_managed",
"account_pattern": "^t_[a-z0-9_]{1,40}$",
"connect": {"host": "pgbouncer.internal", "port": 6432, "database": "app"}
}
PgBouncer
クライアントは PgBouncer を通して接続できます。その場合、固定の userlist.txt は使わず、auth_type = scram-sha-256 と auth_query(SECURITY DEFINER の関数を通して pg_shadow を参照するもの)を設定してください。こうすれば、PgBouncer は常にその時点のベリファイアで照合します。server_reset_query = DISCARD ALL は変えないでください。
PgBouncer がすでに開いているサーバーへの接続は、ローテーションの後もそのまま使えます。
自分が設定した状態の見分け方
どのウィンドウのパスワードが入っているかは、rolvaliduntil を見ればわかります。tenant-pass が設定するのは、いつもそのロールのウィンドウの境目とぴったり一致する時刻だけだからです。NULL、infinity、そのほかの値なら「まだ tenant-pass が管理していない」と判断して上書きします。
VALID UNTIL はそのままで、パスワードだけを誰かが手で変えた場合はどうなるか。クライアントが 28P01 を受け取って repair: true でやり直し、tenant-pass が(同じ)ベリファイアを書き直して元に戻ります。
ログ
サーバーに送るのはベリファイアだけですが、それでも tenant-pass の実行する文をログに残したくなければ、ALTER ROLE tenant_pass_admin SET log_statement = 'none' を設定してください(スーパーユーザーの権限が必要です)。
この文書は、リポジトリにある docs/ja/stores/postgres.md をもとにしています(英語版は docs/en/stores/postgres.md)。