対象: 前の記事でMCPの「読み取り専用」を試して、「チームで使うとき、渡す前に何を確認すればいいの?」が気になった方
所要時間: 40〜60分 / 使うもの: ブラウザ、ターミナル、Supabaseのプロジェクト(Freeプランで可)
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): https://qiita.com/tomozilla/items/3fd871d70b141b56cd7e
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/nbc7a8c57296c
サンプルコード一式(GitHub): https://github.com/tomozilla/mcp-least-privilege-demo
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)はja/フォルダーにあります。
前の記事では、「読み取り専用」のロールが、書き込みは止めるけれど、見える範囲は狭めないことを、SQL Editorで確かめました。
でも、それをチームの人に伝えるのは大変です。「SQLで確かめたら、こうでした」では、AIツールの導入を決める人には届きません。そこで、AIに鍵を渡す前に見る1枚の画面をつくってみました。使うのは、セキュリティ担当や、チームのリーダー、情報システムの人を想像しています。
この画面は2枚で、1つは「棚卸し」(このデータベースで、何がAIから読めてしまうか)、もう1つは「接続URLづくり」(鍵をどこまで狭くして渡すか)です。
0. 先に伝えておきたいこと(正直ベース)
- AIエージェントは、実際には接続していません。(前の記事と同じです。認証が必要で、見送りました。)この画面が調べるのは、データベースの権限の設定です。AIが実際にどう動くかではありません。
- 「AIから読める」の判断は、読み取り専用MCPが使うロール(
supabase_read_only_user)の権限から出しています。このロールを使うことは、ドキュメントの説明です。私は、このロールで実際にSQLを実行したことはありません(前の記事のとおり、切り替えられませんでした)。 - 「見え方」は目安です。ポリシーの中身(条件)は読んでいません。
-
費用:画面を開くと、
exposure-report関数が1回呼ばれます(自動の更新はありません)。Freeプランの月50万回に対して、ほぼ無視できます。他にかかるお金はありません。AIツール側の利用料は、この記事の範囲外です。 - セキュリティ:この関数が返すのは、テーブル名・列名・行数・権限です(行の中身は返しません)。それでも社外に見せてはいけない情報です。関数はVerify JWTをオフにして、「アプリのpublishableキー」を確認するだけの簡単な守りにしています。社外に出すページには、本物のログインを付けてください。
1. つくったもの①:データ棚卸し
テーブルごとに、誰に、どう見えるかを並べます。
| 列 | 見えること | どこから取る? |
|---|---|---|
| RLS | RLSがオンか、ポリシーの数 |
pg_class・pg_policies
|
| ブラウザ(ログインなし) |
anon ロールに、読める権限とポリシーがあるか |
has_table_privilege・pg_policies
|
| ログインしたユーザー |
authenticated ロールに、同じ判定 |
同上 |
| AI(読み取り専用の接続) | 読み取り専用ロールに、読める権限があるか | has_table_privilege |
画面の一番上に、赤い警告が出ます。
読み取り専用のAI接続は、RLS(行ごとのアクセス制限)を通りません。このデータベースでは、AIが読めるテーブルが5個あり、その全行が見えます。
前の記事で確かめたとおり、このロールは rolbypassrls = true(RLSをすり抜ける)でした。そのため、このデモのデータベースでは、5つのテーブルすべてが、AIから全行読めるという結果になりました。
一番「おっ」と思ったのは、meeting_notes(面接メモが入っているテーブル)です。
- ブラウザ(ログインなし):見えない(RLSはオンで、ポリシーが0個だから)
- ログインしたユーザー:見えない
- AI(読み取り専用の接続):全行読める
つまり、アプリのAPIからは誰にも見えないのに、AIの接続からは全部見えるテーブルです。面接メモは個人情報です。RLSで守っているから安心、と思っていたデータが、AIの接続からは丸見えになりうる。画面で一覧にして初めて、実感しました。
「見せたくない」と決めてみる
右端の列で、テーブルごとに「見せてよい」「見せたくない」を決められます(ブラウザの localStorage に保存するだけで、サーバーには送りません)。
invoices と meeting_notes を「見せたくない」にすると、「見せたくないと決めたテーブルが、2個、AIから読めます」と警告が出ます。あなたの判断と、現実のズレを、赤で見せる画面です。
画面の下には、見せたくないデータがあるときの考え方の例を載せました(開発用のプロジェクトにつなぐ、別のプロジェクトに置く、範囲・書き込み禁止・道具を絞る、人が承認する)。「別のプロジェクトに置く」は、私は試していません。
2. つくったもの②:接続URLづくり
2つ目のタブでは、MCPの接続URLを、チェックを入れてつくります。ダッシュボードの Connect → MCP で見たURLの形を、そのまま組み立てています。
プロジェクトのID(画像は、架空のIDです)を入れて、読み取り専用と3つの道具(docs / database / debugging)だけにすると、「渡す鍵の広さ:狭い」と出ます。右には、AIツールに追加するコマンド(Claude Codeの例)が出ます。コマンドは画面では実行しません。
わざと広くすると、こうなります。
読み取り専用を外し、プロジェクトのIDを空にし、account branching functions を足すと、「広い」になり、危険・注意が並びます。
各グループの説明は、ツール名から私が読み取ったものです(前の記事で、
create_projectやdelete_branchといった名前を見ました)。「project_refを付けるとaccountが無効になる」は、ドキュメントの説明で、私は確かめていません。画面にも、そう書きました。
3. つくり方
app/index.html 画面(HTML 1枚、ビルド不要)
supabase/functions/exposure-report/index.ts 棚卸しのAPI(読むだけ)
APIは、カタログ(データベースの設計情報)を調べるだけです。
select c.relname as name, c.relrowsecurity as rls_enabled,
has_table_privilege('anon', c.oid, 'select') as anon_select,
has_table_privilege('authenticated', c.oid, 'select') as authenticated_select,
has_table_privilege('supabase_read_only_user', c.oid, 'select') as mcp_select
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r';
動かすときは、前の記事のアプリと同じです。
# exposure-report を Edge Function としてデプロイ(Verify JWT はオフ)
cd app
cp config.example.js config.js # URL と publishable キーを入れる
python3 -m http.server 8765 --bind 127.0.0.1
4. つまずいたこと
① 「ブラウザ(ログインなし)」の判定が、まちがっていた
最初の版では、invoices が、ブラウザ(ログインなし)にも「条件つきで見える」と出ていました。ポリシーが2個あるから、と判断したのです。
不安になったので、実際にログインなしで、APIから読んでみました。
| テーブル | ログインなしで読めた行数 |
|---|---|
| invoices | 0 |
| meeting_notes | 0 |
| memberships | 0 |
| posts | 1(公開済みの記事) |
| tenants | 0 |
「条件つきで見える」と出していた4つのテーブルのうち、実際に読めたのは**posts の1件だけでした。画面が嘘をついていた**のです。
原因は、ポリシーがどのロール向けかを見ていなかったことでした。invoices のポリシーは to authenticated(ログイン済みの人向け)だけなので、ログインなしの人には当てはまりません。直し方は、「読む(SELECT)ためのポリシーのうち、そのロールに当てはまるものの数」を数えることでした。
(select count(*)::int from pg_policies p
where p.schemaname = 'public' and p.tablename = c.relname
and p.cmd in ('SELECT', 'ALL') and (p.roles && array['anon', 'public']::name[])) as anon_select_policies
直したあとは、ログインなしの列が、実際の結果(posts だけ読める)と合うようになりました。ただし、ポリシーの「条件」(using の中身)までは見ていません。 そのため「条件つき」は、「条件に合えば見える」の意味で、実際に何行見えるかは分かりません。
学び:権限を表示する画面は、実際に読んでみて、突き合わせる。 「ポリシーがある」と「そのロールが読める」は、別のことでした。
② 「個人情報かも」の判定は、当てにならない
列名に name mail phone などが入っていると、「個人情報かも」のタグを付けています。tenants の name は会社名なのに、タグが付きました(上の画面)。逆に、中身が個人情報の列でも、名前が content なら、何も付きません(meeting_notes.content がそうです)。
列名での判定は、目印程度です。最終的には、人が中身を見て決めるしかなさそうです。
③ 画面を直したら、一覧の幅があふれた
判定の表示を長くしたら、一番右の列がカードからはみ出して、選択肢が切れました。ラベルを短くして(「見えない」「条件つき」「全行」)、詳しい説明は、表の下の注意書きに移しました。
5. 実際の運用にするなら
どれも、私は試していません。
- 本物のAI接続との突き合わせ:権限の設定から出した目安が、実際のエージェントの動きと合うかを確かめる
-
ポリシーの中身の解析:
usingの条件まで読んで、「何行見えるか」を出す - 列ごとの棚卸し:テーブル単位でなく、列単位(個人情報の列だけ隠す、など)
- 判断の保存と共有:今は自分のブラウザだけ。チームで共有して、承認の記録を残す
- 定期実行:テーブルやポリシーが増えたら、自動で「見せたくないのに読める」を見つける
- ログイン:棚卸しの結果は、社外に出してはいけない情報です
まとめ
- AIに鍵を渡す前の確認を、1枚の画面にした(棚卸し + 接続URLづくり)
- このデモでは、読み取り専用のAI接続から、5つのテーブルすべてが全行読める結果になった
- アプリのAPIからは誰にも見えない(RLSオン・ポリシーなし)テーブルも、AIの接続からは見える
- 「見せたくない」と決めたテーブルが読めるときは、赤い警告で、判断と現実のズレを見せられる
- 最初の版は、ログインなしの判定がまちがっていた。実際に読んで、突き合わせて直した
- 実際のAIエージェントとの接続は、私は未検証
コードは、上のGitHubにあります。次は、Realtimeを使った「みんなで同時に見る」ライブ注文ボードです。
スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。画像のプロジェクトIDは、架空のものです。




