tenant-pass

PostgreSQL

テナントごとの PostgreSQL のロールを管理する仕組みと、必要な権限だけを与える準備の手順を説明します。SCRAM ベリファイアと VALID UNTIL、PostgreSQL 16 以降が必要な理由、安全性チェック、PgBouncer を扱います。

このページの見出し
  1. 仕組み
  2. 対応するバージョン
  3. 準備(PostgreSQL 16 以降)
  4. 安全性チェック
  5. ノードの設定
  6. PgBouncer
  7. 自分が設定した状態の見分け方
  8. ログ

仕組み

テナント 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)。