02データベース5 min

Postgres の RLS を、使った二回とも外した理由

Row Level Security はデフォルトで正しい作法に見えます。Drizzle とサーバーアクションと自前の認証があると、権限のロジックはすでに別の場所に住んでいて、両方を保守するのは痛い。

著者: Salva Castells

カバー画像: Paulo Humberto, CC BY-SA 3.0.
この記事の内容

プロフィールのスキーマには、Neon 向けの Drizzle ヘルパーで書いたこんなポリシーがありました。

crudPolicy({
  role: authenticatedRole,
  read: authUid(table.userId),
  modify: false,
})

三行です。認証済みユーザーは自分の行しか見えず、変更もできない、と言っています。反論しにくい。一年半後、その三行を、フィードバックのテーブルのものと一緒に同じコミットで消しました。数か月後、同じスキーマを継承していた別のプロジェクトでも同じ削除を繰り返しました。

気まぐれではありません。ポリシーとコードが同じことを言っていて、二か所が同じことを言うと、いずれ片方が嘘をつくからです。

RLS の約束と、それを買った理由

約束は良いものです。権限がデータベースに住むので、誰が問い合わせても関係ない。明日メンテナンス用のスクリプトを書いても、新しいエンドポイントを開いても、誰かが where を書き忘れても、その行は出てきません。飾りではなく、本物の多層防御です。

その周りに全部を組み立てました。接続は二つ。

export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
  const authToken = (await user?.getAuthJson())?.accessToken;
  const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
  const sql = neon(url, { authToken });
  return drizzle(sql);
};

ユーザーの JWT を運んでポリシーを尊重する「認証済み」の接続と、通常の DATABASE_URL を使ってポリシーを飛び越える管理者用の接続です。ユーザー ID ごとに RLS 接続のマップをキャッシュすることまでやりましたが、有効期限のあるトークンに縛られた接続を保存しているのだと気づき、毎回新しく作るようにしました。

Drizzle とサーバーアクションで軋み始めたところ

問題は RLS が壊れることではありません。Next とサーバーアクションのアプリでは、RLS が決定する層になることがほとんどない、ということです。

各サーバーアクションはすでにセッションの解決から始まります。データベースに触る前にユーザーは手元にあります。そしてこう書くことになります。

const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));

この where はポリシーと重複しています。でも外せません。外すとコードが嘘をつくからです。全フィードバックを取ってくるように見えて、実は別ファイルにある目に見えないポリシーに依存している状態になります。結局フィルタはコードにもデータベースにも置かれ、どちらが効いているのかという疑いが残ります。

二つめは日々の摩擦です。

  • 接続が二つ。 新しい関数はどれも、RLS で行くのか管理者で行くのかという問いから始まります。そして管理系、決済の webhook、cron、マイグレーションはすべて管理者側を通ります。つまり、何も検証しない側です。
  • エラーが何も説明しない。 ポリシーにブロックされても「claim が足りない」とは言ってくれません。ゼロ件の行が返ってくるだけです。行は存在するのに空配列を返す select のデバッグは、見た目より高くつきます。
  • トークンが行ってほしくない場所へ行く。 認証済み接続の URL は NEXT_PUBLIC_ 接頭辞の変数に収まり、その時点で何を晒すのか二度考えることになります。
  • ポリシーがスナップショットに住む。 ポリシーを変えるにはマイグレーションを生成して適用します。コードのチェックを変えるのは一行の変更です。

実のところ、ある時点で Drizzle を丸ごと迂回して、フィードバックとプロフィールを PostgREST 経由にしたこともあります。本当に RLS に寄りかかるためです。そこで代償がはっきりしました。型が失われ、カラム名を snake_case(user_id、created_at)で手書きすることになり、呼び出しはどれも同じ儀式で終わります。

const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];

最後に as Feedback[]。つまり、自分の言うとおりのものをデータベースが返すと信じる、ということです。二週間後、Drizzle に戻りました。

二つめのプロジェクトでも同じ結末

同じボイラープレートから次のプロジェクトを始めたとき、ポリシーは中に入ったまま来ました。フィードバックのテーブルを本気で触るまでのあいだしか、もちませんでした。それを外したコミットは、同時に別のこともしています。feedback.description を feedback.content にリネームすることです。間抜けな細部ですが、話の全部を語っています。データモデルを形にしている最中で、それを動かすのにポリシーが邪魔だったのです。そのコミットで drizzle-orm/neon の import と四つの crudPolicy 呼び出しが消えました。テーブルには、毎日本当に使われるものだけが残りました。

(table) => [index("idx_feedback_user_id").on(table.userId)]

user_id のインデックスひとつ。これは Postgres がリクエストのたびに参照します。

いま権限のロジックが住んでいる場所

ミドルウェアと、サーバーアクションの層です。マネージドな認証をやめて自分でセッションのミドルウェアを書いたとき、誰が何を見るかを決める場所は一点に集約されました。セッションを解決し、ロールを確認し、そこから先のクエリはすべて userId を手に持った状態で書きます。接続はひとつ、管理者用のものだけで、並行する特権経路はありません。

紙の上では安全性が下がります。where を忘れたクエリは他人の行を返します。埋め合わせているのは、それらのクエリが全部アクション用のディレクトリに住んでいてアプリ中に散らばっていないことと、関数のシグネチャがユーザーを渡すことを強制することです。

また RLS を使う場合

データベースにクライアントが複数あるとき。自分のアプリに加えて公開の PostgREST があったり、直接つなぐクライアントがいたり、読み取り専用のアナリストがいたりするなら、ポリシーはその規則を全員に対して成立させられる唯一の場所です。そこでは二つの真実を保守するコストが十分に元を取ります。

厳密なマルチテナントでも同じです。失敗が「他人のフィードバックが見える」ではなく「他社のデータが見える」であるとき。そこではコードが絞っていても下に網が欲しい。

クライアントがひとつのアプリで、サーバーアクションがあり、最初のクエリの前にユーザーが解決されているなら、RLS はすでに書いてある規則の二つめのコピーです。そして二度書かれた規則は二倍安全ではありません。二倍、古いまま放置されやすいだけです。