Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

RLSを有効にしても、TRUNCATE権限が残っていれば安全ではなかった

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgreSQLのTRUNCATEはRLSポリシーの対象外の操作であるため、RLSを有効にしポリシーを1つも作らなくても、anon/authenticatedにTRUNCATE権限が残っていればテーブルを丸ごと空にできる。

結論

RLS(Row Level Security)という、行ごとのアクセスをポリシーで制限する仕組みを有効にし、ポリシーを1つも作らずに anon / authenticated を締め出したつもりでも、テーブルに TRUNCATE 権限が残っていればテーブルを丸ごと空にできる。TRUNCATE はRLSポリシーの対象外の操作だからで、Supabase の既定権限は明示的に外さない限り TRUNCATE まで含んだ状態で付いてくる。

症状

チャットボットの知識ベース(chat_knowledge)というテーブルがある。中身はそのまま LLM の system プロンプトに入るため、書き込めた人間がボットの発言内容を決められる。設計時点の意図はテーブルのコメントにそのまま書かれている。

-- チャットボットの知識ベース(RAG)
--
-- ⚠ この表の中身は、そのまま LLM の system プロンプトに入る。
--   書き込めた人がボットの発言内容を決められるということ。RLS を有効にしたうえで
--   ポリシーを1つも作らない= anon / authenticated からは読めも書けもしない。
--   触れるのは service role(サーバー側の API ルート)だけ。

ENABLE ROW LEVEL SECURITY を実行し、CREATE POLICY は1つも書かない。RLS はデフォルト拒否なので、ポリシーがゼロならどのロールも SELECTINSERT も通らない、というのが設計の建て付けだった。ところがローカルに Supabase を立てて実データで検証していたところ、anon / authenticatedTRUNCATE 権限が付いたままになっていることに気づいた。RLS ポリシーは0件のままである。

原因

PostgreSQL の RLS ポリシーが対象にしているのは SELECT / INSERT / UPDATE / DELETE という行単位の操作で、TRUNCATE はそもそも対象に含まれない。テーブルを丸ごと空にする TRUNCATE はテーブル単位の操作であり、行ごとの可視性・書き込み可否を判定する RLS の仕組みそのものが関与しない。判定するのは「そのロールに TRUNCATE 権限があるかどうか」だけで、これは RLS とは独立した通常のテーブル権限(GRANT/REVOKE)の話になる。

そしてこのテーブルは、TRUNCATE 権限を明示的に付与した覚えが無いにもかかわらず、anon / authenticated に付いていた。Supabase はテーブルを作成すると既定でロールに広い権限(GRANT ALL 相当)を与える形になっており、何も指定しなければ TRUNCATE もその中に含まれる。「RLS を有効にしてポリシーをゼロにしたから安全」という設計上の建て付けは、RLS が及ばない TRUNCATE という操作の存在を計算に入れていなかった。

直す

anon / authenticated からはテーブルへの権限を全て剥がし、実際にアクセスする service_role にだけ必要な権限を明示的に付け直した。

-- ⚠ 権限は明示的に与える。Supabase の既定権限(ALTER DEFAULT PRIVILEGES)に頼ると、
--   migration をどのロールで流したかによって付いたり付かなかったりする。
--   2026-08-16 にローカル Supabase で確認: CLI から流すと service_role に権限が付かず、
--   チャットが permission denied for table chat_knowledge で 502 になった。
-- ⚠ anon / authenticated からは全部剥がす。Supabase の既定権限は GRANT ALL なので、
--   放っておくと TRUNCATE まで付く。**TRUNCATE は RLS を迂回する**ので、
--   「RLS を有効にしたから安全」は成り立たない(2026-08-16 にローカルで確認)。
REVOKE ALL ON public.chat_knowledge FROM anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.chat_knowledge TO service_role;

REVOKE ALLTRUNCATE を含む全権限を anon / authenticated から剥がし、service_role には必要な4操作だけを明示的に GRANT する。RLS ポリシーをゼロにするだけでは閉め出せない TRUNCATE の穴は、RLS の設定ではなく通常の権限管理側で塞ぐしかない。

同じコミットでは service_role 側の抜けも一緒に直している。Supabase の既定権限は migration をどのロールで流したかによって挙動が変わり、ローカル検証時は CLI から流したことで service_role に権限が付かず、逆にチャットが permission denied for table chat_knowledge で 502 になっていた。「Supabase の既定に任せる」という前提そのものが、多い方にも少ない方にもぶれる不安定な土台だったため、権限は都度 GRANT / REVOKE で明示する方針に変えた。

再発防止

RLS を有効にしてポリシーを0件にする設計は、SELECT / INSERT / UPDATE / DELETE の4操作には正しく効く。ただしそれで「このテーブルはロックされている」と結論づけるのは早く、TRUNCATEDROPALTER TABLE といったテーブル単位の操作は別枠として権限を確認する必要がある。RLS を入れたテーブルほど「もう安全なはず」という思い込みが働きやすく、通常の GRANT/REVOKE を見直す機会を素通りしやすい。Supabase のように既定権限が広めに付くプラットフォームでは、RLS の有無にかかわらず anon / authenticated に不要な権限が残っていないかをテーブルごとに確認する習慣が要る。

よくある質問

Q1なぜRLSを有効にしてポリシーをゼロにしただけでは不十分なのですか?

RLSのポリシーはSELECT/INSERT/UPDATE/DELETEといった行単位の操作にしか働かず、TRUNCATEには適用されないため。ポリシーが1つも無くてもTRUNCATE権限そのものを持っていればテーブル全体を空にでき、RLSは関与しない。

Q2なぜanon/authenticatedにTRUNCATE権限が付いていたのですか?

明示的に付与した覚えは無かったが、Supabaseの既定権限がテーブル作成時にGRANT ALLを与える形になっており、その中にTRUNCATEも含まれていた。RLSを有効にすれば安全という前提で、権限自体の見直しをしていなかった。

Q3service_roleに明示的にGRANTした理由はTRUNCATEとは別の話ですか?

別の問題。migrationをどのロールで流すかによってSupabaseの既定権限が付いたり付かなかったりし、ローカル検証ではservice_roleに権限が付かずチャットがpermission deniedで502になっていた。権限は既定に頼らず明示的に書く方針にした。

Q4この問題はどうやって見つけたのですか?

ローカルにSupabaseを立てて実際の埋め込みと回答で検証している最中に、権限周りをテーブルごとに洗い直して見つかった。4件の不備のうちの1つで、他は502エラーや検索閾値の緩さなど別種の問題だった。

確認した環境

  • Supabase (@supabase/supabase-js ^2.53.0) / ローカル環境
  • 2026-08-16 にローカル Supabase の実データ検証で発覚・修正

この記事の根拠

  • SQLファイル 1〜9行目コミット 47a8735
  • SQLファイル 37〜47行目コミット 47a8735

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。