0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

人には隠し、AI には告げる — このロールは何に触れてよいかを説明する設計

0
Posted at

🔗 この記事は 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_viewkozou 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 }
    // …
  ]
}

selectfalse — このロールはこのテーブルを読む権限を持たない。ここに出ているのは 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 viewscustomers は一覧から消え、SELECT を与えていないビュー3つも丸ごと消えている。同じ瞬間、MCP と kozou docs4テーブルとビュー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行で添えられる。

そして、ここに無いものが重要だ。USINGWITH CHECKポリシー式そのものは、一切渡していない。読んでいるのはブール値だけで、式は取得すらしない。

理由は3つある。

  1. 認可ロジックを2箇所に置くことになる。コンテキストに写した認可の説明は、必ず古びる。前回「注記には賞味期限がある」と書いたのと同じ問題が、今度は認可で起きる。しかも古びた認可の説明は、古びたコメントより性質が悪い。
  2. 知っても迂回できない。式を読めたところで、エージェントに RLS を回避する手段はない。強制するのは DB だ。渡す利得が、そもそも小さい。
  3. 式自体が機密になりうる。誰をどう区別しているかは、それ自体が漏らしたくない情報であることが多い。

失うものもはっきりしている。エージェントは「なぜ弾かれたのか」を説明できない。書き込みの拒否はエラーとして返るからまだいいが、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. 最小権限のロールを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. 設定を2行足す。

    introspection:
      respectPrivileges: true
      role: support_agent
    
  3. 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.orgGitHub(Apache-2.0)にある。上のデモスキーマは quickstart にそのまま入っている。

まとめ

  • 同じ権限情報を、人には隠し、AI には注記する。隠されたエージェントは、無いものを推測で埋めてしまうと考えたからだ。
  • RLS はブール値だけを渡す。ポリシー式は読まない — 認可ロジックを DB の外に写さないため。
  • 役割依存の事実は opt-in、構造的な事実は既定で。 そして説明したロールと実行するロールは一致させる。
  • 注記は「通る」ことを保証しない。insert: true でも RLS が拒否することはある。

守らせているのは、最初から PostgreSQL だ。限界を伝えて変わるのは、エージェントがその限界の内側で働けるかどうかのほうだ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?