🔗 この記事は https://blog.tak3.jp/ja/blog/kozou-privilege-aware-context/ からの転載です(一次情報源)。
権限を絞ったロールで AI エージェントに DB を触らせると、こういうことが起きる。エージェントは、自分が読めないテーブルに向かって堂々とクエリを書く。返るのは permission denied だ。あるいは、権限で絞り込まれたカタログしか渡されていない場合には、そこに無いものを「無い」と解釈して、名前の似た別の列で代用した数字を、自信たっぷりに差し出してくる。
権限は効いている。効いていないのは伝達のほうだ。PostgreSQL は GRANT と行レベルセキュリティ(RLS)で止めている。止められた側だけが、止められる理由も範囲も知らされていない。
では、「このロールは何に触れてよいか」を、エージェントにどう伝えるべきか。そして — 伝えることは、権限を弱めることにならないか。
本稿は、Kozou(PostgreSQL のスキーマから AI 向けコンテキスト・管理 UI・REST を生成するコンパイラ)でこの問いにどう答えたか、という設計判断の記録だ。結論から言えば、答えは「隠さずに告げる」。ただし 人間の管理画面には隠し、AI のコンテキストには注記する という、同じ情報の逆向きの出し方として。
以下の挙動とペイロードは v1.17.0 時点のもので、題材には Kozou の quickstart に同梱されたデモスキーマ(小さなオンラインストア。customers / products / orders / order_items とレポーティング用のビュー3つ)を使う。権限とロールは本稿のために足した。なお権限の注記は v1.8.0 以降、RLS のシグナルは v1.11.0 以降の機能なので、古い版では設定を足しても何も出ない。
前提 — 強制は DB に残す
Kozou の紹介記事で一線を引いた。Kozou は意味を渡すが、強制はしない。実際のアクセス制御は PostgreSQL 側 — 権限と RLS — に残る。入門2本目では、書き手の側から同じことを書いた。本当に見せたくないなら、コメントではなく権限で止めろ、と。
本稿はその線の外側を扱う。権限で止めた。ではその事実を、エージェントはどうやって知るのか。
判断1 — 隠すか、注記するか
Kozou の権限対応は opt-in で、有効にするとひとつのロールを基準に「そのロールが何をできるか」が2つの面に反映される。ところが、その2面で出し方が逆になる(REST API と OpenAPI は対象外で、スキーマ全体を出したままだ。こちらはリクエストごとに呼び出し側のロールと RLS で強制する)。
-
管理 UI(人間向け):
SELECTできないテーブルはナビゲーションから消える。書き込めない列はフォームから消えはしないが、モードごとに読み取り専用になる(INSERT権限が無ければ作成フォームで、UPDATE権限が無ければ編集フォームで)。 -
MCP の
describe_table/describe_viewとkozou docs(AI 向け): 何も隠さない。関係はすべて残したまま、「このロールはこれができる/できない」を注記する。
実物で見たほうが早い。以下は、サポート窓口を想定した support_agent というロール — 注文は読めるが、顧客テーブル(個人情報)には SELECT 権限すら与えていない — を基準にしたときの、describe_table("public.customers") の出力だ。
{
"qualifiedName": "public.customers",
"privileges": { "role": "support_agent", "select": false, "insert": false, "update": false, "delete": false },
"columns": [
{ "name": "id", "insertable": false, "updatable": false },
{ "name": "full_name", "insertable": false, "updatable": false },
{ "name": "email", "insertable": false, "updatable": false }
// …
]
}
select が false — このロールはこのテーブルを読む権限を持たない。ここに出ているのは GRANT の有無だけで、後で見る RLS とは独立に評価される(Kozou は has_table_privilege で問い合わせている)。それでもテーブルは丸ごと返ってくる。列も、@ai 注記も、@policy に書いたビジネスルールも、すべて添えられたままだ。kozou docs(Markdown のスキーマドキュメント)でも同じで、そのテーブルの節は消えず、Security の行が全部 no になる。
**Security** — effective privileges for role `support_agent` (advisory; PostgreSQL enforces access):
| SELECT | INSERT | UPDATE | DELETE |
| --- | --- | --- | --- |
| no | no | no | no |
では、同じロール設定のまま管理 UI を開くとどうなるか。ヘッダーにはこう出る — 3 tables / 0 views。customers は一覧から消え、SELECT を与えていないビュー3つも丸ごと消えている。同じ瞬間、MCP と kozou docs は4テーブルとビュー3つ、合計7つすべてを返し、そのそれぞれに「できる/できない」を書き添えている。
同じ権限情報、同じ設定、逆の出し方だ。
なぜ逆にするのか
人は、無いものを探さない。見えないボタンは押されないし、無いメニューの機能を要求してくることもない。人間向けの UI では、できないことを見せないのが親切であり、事故も減る。
エージェントは逆だと考えた。無いものを推測で埋めてしまうのではないか、と。テーブルが見えなければ「そんなテーブルは無い」と断定し、近い名前の列を代用に持ち出す。これは前回書いた失敗と同じ形をしている — 生の DDL しか見えない AI が、どの列が罠かを知らないまま、もっともらしく間違うのと同型のことが、今度は権限の側で起きる。これは計測した結果ではなく、設計上そちらに賭けた、という話だ。
だから AI には「ここにある。ただしあなたは読めない」と告げるほうがいい。"select": false は拒否ではなく、情報だ。エージェントは、試す前に自分の限界を知る。
この出し分けは Kozou のコードにもそのまま書かれている。設定スキーマの脇にはこうある — 管理 UI は "hides tables the role cannot SELECT"、MCP と docs は "do NOT hide — they keep every relation and annotate it"。ドキュメント生成の内部でも、権限モードのときに privilegeDisplay: 'annotate' という値が渡っていく。隠すのか注記するのか、が語彙になっている。
採らなかった案: AI 側でも隠して、見えるスキーマだけを渡す。これは一見「余計な情報を与えない」ように思えるが、エージェントは存在しないと学習し、失われた全体像を推測で埋め始める。渡す情報は減るのに、間違いは増える。
判断2 — ブールだけを読む
権限には、テーブル単位の GRANT のほかに行レベルセキュリティがある。RLS についてエージェントに渡しているのは、3つのブール値と1行の注意書きだけだ。
orders(RLS 有効・ポリシーあり):
"rowSecurity": {
"enabled": true, "forced": false, "hasPolicies": true,
"note": "Row-level security is enabled: the rows you can read and the rows you can write are filtered by policy for the connecting role, so do not assume a result is complete or that a write will be accepted."
}
customers(RLS 有効・ポリシーを1つも作っていない):
"rowSecurity": {
"enabled": true, "forced": true, "hasPolicies": false,
"note": "Row-level security is enabled but no policy is defined, so non-owner roles can read and write no rows (default-deny). RLS also applies to the table owner (roles with BYPASSRLS still bypass it)."
}
「ポリシーを書いていない」ことが最も強い制限になる、という PostgreSQL の癖 — RLS を有効にしてポリシーが無ければ default-deny — が、そのままエージェントに伝わる。forced は所有者にも RLS を適用する設定で、これも1行で添えられる。
そして、ここに無いものが重要だ。USING や WITH CHECK のポリシー式そのものは、一切渡していない。読んでいるのはブール値だけで、式は取得すらしない。
理由は3つある。
- 認可ロジックを2箇所に置くことになる。コンテキストに写した認可の説明は、必ず古びる。前回「注記には賞味期限がある」と書いたのと同じ問題が、今度は認可で起きる。しかも古びた認可の説明は、古びたコメントより性質が悪い。
- 知っても迂回できない。式を読めたところで、エージェントに RLS を回避する手段はない。強制するのは DB だ。渡す利得が、そもそも小さい。
- 式自体が機密になりうる。誰をどう区別しているかは、それ自体が漏らしたくない情報であることが多い。
失うものもはっきりしている。エージェントは「なぜ弾かれたのか」を説明できない。書き込みの拒否はエラーとして返るからまだいいが、SELECT はもっと静かだ — RLS は該当しない行を黙って除外し、正常な結果として返す。だから「結果が完全とは限らない」という注意書きのほうを渡している。理由の説明までは引き受けない、という割り切りだ。
採らなかった案: ポリシー式を要約して渡す。要約が古びた瞬間、エージェントは自信を持って嘘をつくようになる。「この行は見えるはずです」と。
判断3 — 何を opt-in にするか
ここまでに2種類の情報が出てきた。ロールの権限(privileges)と、RLS のシグナル(rowSecurity)だ。この2つは既定値が逆になっている。
同じデータベースに対して、設定だけを変えて describe_table を2回叩くと差が見える。
| フィールド | 既定 | respectPrivileges: true |
|---|---|---|
privileges |
出ない | 出る |
列の insertable / updatable
|
出ない | 出る |
rowSecurity |
出る | 出る |
判断の基準は一文で書ける。役割依存の事実は opt-in、構造的な事実は既定で出す。
privileges は「誰の権限か」が決まらないと嘘になる。だから出力自身が "role": "support_agent" と名乗る。ロールを決めないまま権限像を配れば、それは情報ではなく誤情報だ。一方 RLS が有効かどうか、ポリシーがあるかどうかは、テーブルの構造的な性質でロールに依存しない。だから黙って渡してよい。
採らなかった案: 両方とも既定で出す(ロール未設定のとき、誰のものでもない権限像を配ることになる)。両方 opt-in にする(default-deny のテーブルをエージェントが黙って踏み、原因の分からない空結果を根拠に結論を出す)。
なお、権限の評価にそのロールで接続する必要はない。has_table_privilege / has_column_privilege で問い合わせているので、注記のために権限を借りることはしていない。
判断4 — 説明と実行を食い違わせない
ここまでは describe、つまり「説明」の話だった。Kozou は MCP 経由で関数を実行することもできる(これも opt-in)。実行が絡むと、説明の正しさに新しい条件がつく。
実行を有効にすると、注記の基準にしたロールが、実行するロールに縛られる。エージェントは自分でロールを選べない。自己昇格が「禁止されている」のではなく、選ぶ余地が構造的に無い。
リモートの MCP を OAuth のリソースサーバとして運用する場合は、検証済みトークンごとの PostgreSQL ロールで実行し、実行してよいロールは明示的な許可リストを必須にしている。ただし権限の注記と併用できるのは、その許可リストがちょうど1ロールのときだけだ。それ以外は起動を拒否する — 呼び出し側ごとにロールが変わるのに、注記が1つのロールを名乗ったら、それは嘘になるからだ。呼び出し側ごとの注記は、まだ無い。
理由は単純だ。「読めます、書けません」と説明した相手と、実際に実行する主体が別のロールなら、エージェントは正しい説明を前提に、間違った実行をする。説明の正しさは、実行との一致でしか担保できない。
採らなかった案: 注記するロールと実行するロールを別々に設定できるようにする。設定の自由度は上がるが、食い違ったまま運用できてしまう。だから設定できないようにした(食い違う組み合わせは起動時に落ちる)。
境界 — これは権限ではない
最後に、やっていないことを並べておく。
注記は権限ではない。 "select": true は「読めるはずだ」という助言であって、許可の発行ではない。許可を出すのは GRANT と RLS であり、Kozou がそこに足せるものは何もない(実行側では、公開する関数を許可リストで狭めることはある)。同じ思想は関数の公開にもあって、@expose: rpc で公開された関数を実行できるかどうかは EXECUTE 権限が決める。露出は権限ではない。
単一ロールでの実行は、マルチテナントの per-user 認可には使えない。 呼び出し側ごとの本人性が無いからだ。そこは REST か、トークンごとにロールが決まる OAuth 側の仕事になる。
ただし、代償はある。 隠さないということは、そのロールが読めないテーブルの名前も、列も、そこに書かれた業務上の注記も、コンテキストには載るということだ。データへのアクセス権は1ミリも動かないが、スキーマというメタデータの開示範囲は広がる。この面を見せる相手が DB のロールより広いなら — 例えば MCP エンドポイントを外に出すなら — そこは別に設計する必要がある。
そのうえで冒頭の問いに戻る。伝えることは、権限を弱めない。 データの可否は PostgreSQL が握ったままだ。むしろ危ないのは、限界を知らないエージェントのほうだと考えている。当てずっぽうの回避策を書き、空の結果を「データが無い」と読み替えて、そのまま結論にしてしまう。
試してみる
自分のスキーマで確かめるなら、手順は3つだ。
-
最小権限のロールを1つ作る。 コツは、
SELECTを持たないテーブルを意図的に1つ残すこと。そこでこの設計の一番おもしろい部分が見える。CREATE ROLE support_agent NOLOGIN; GRANT USAGE ON SCHEMA public TO support_agent; GRANT SELECT ON orders, order_items, products TO support_agent; -- customers は与えない GRANT INSERT ON orders TO support_agent; -- INSERT の GRANT だけ与える -
設定を2行足す。
introspection: respectPrivileges: true role: support_agent -
describe_tableを叩く。kozou docsでも Security の節が出る。ただし列ごとのinsertable/updatableは MCP のペイロードにしかない(docs はテーブル単位の4動詞まで)。ビューに付くのは関係単位の権限だけだ — PostgreSQL 自体はビューの列にもGRANTできるが、Kozou は列単位の権限をテーブルからしか採っていない。
上の GRANT を当てた orders は、こう返る。
"privileges": { "role": "support_agent", "select": true, "insert": true, "update": false, "delete": false },
"columns": [
{ "name": "status", "insertable": true, "updatable": false },
{ "name": "channel", "insertable": true, "updatable": false }
// …
]
ところが、この例を書きながら自分の記事に足をすくわれた。このデモの orders は RLS を有効にしてあり、用意したポリシーは SELECT 用の1本だけだ。PostgreSQL では、RLS が有効なテーブルへの INSERT には INSERT 用のポリシーが要る。だから実際に入れようとすると、こうなる。
ERROR: new row violates row-level security policy for table "orders"
GRANT INSERT はある。ペイロードも "insert": true と言っている。それでも PostgreSQL は拒否する。注記が言っているのは「権限がある」であって、「通る」ではない。 本稿が最初から書いてきたことの、いちばん具体的な形がこれだ。そして同じペイロードには rowSecurity が並んでいて、「書き込みは拒否されうる」と警告している。エージェントに渡すべき情報は、どちらか片方では足りない。
権限モードで起動したときは、そのことがログにも出る。
[kozou mcp] privilege-aware context ON: describe tools annotate what role "support_agent" may touch (advisory; enforcement stays in PostgreSQL)
advisory; enforcement stays in PostgreSQL — この記事は結局、その一行が設計として何を意味するかを書いたものだ。
Kozou は kozou.org と GitHub(Apache-2.0)にある。上のデモスキーマは quickstart にそのまま入っている。
まとめ
- 同じ権限情報を、人には隠し、AI には注記する。隠されたエージェントは、無いものを推測で埋めてしまうと考えたからだ。
- RLS はブール値だけを渡す。ポリシー式は読まない — 認可ロジックを DB の外に写さないため。
- 役割依存の事実は opt-in、構造的な事実は既定で。 そして説明したロールと実行するロールは一致させる。
- 注記は「通る」ことを保証しない。
insert: trueでも RLS が拒否することはある。
守らせているのは、最初から PostgreSQL だ。限界を伝えて変わるのは、エージェントがその限界の内側で働けるかどうかのほうだ。