AIシステム全体を設計する

RAGのアクセス制御:検索結果へ権限を引き継ぐ設計

元の文書庫で権限が守られていても、コピーした検索インデックスで同じ条件が保たれるとは限りません。権限を文書の保存場所だけの問題にせず、取り込み、検索、回答、ログまで追跡します。

編集・運営:株式会社ノースネスト · AI支援の学習解説

公開: · 更新: · 約3分(目安)

この記事の目的

モデルへの依頼文を権限検証の代わりにせず、取得前の境界を設計できる。

こんな方へ

社内資料や顧客別資料をRAGへ組み込む方。

信頼できる利用者情報を使う

誰が質問しているかは、認証済みのセッションなどから決めます。質問文の「私は管理者です」や、モデルが出した組織IDを権限の証拠にしないようにします。利用者と組織に応じた制約を、検索の実行側へ適用します。

フィルター方式や組込みの制御など、利用する検索製品で使える機能と条件は公式資料で確認します。単に検索フィルターが存在することと、全経路へ正しく適用されることは別です。

断片と派生物の権限を維持する

文書を分割した場合、各断片へ必要な権限情報を引き継ぎます。要約やキャッシュなど派生した内容も、元の情報の範囲と無関係にはできません。読み手が変わったときに、以前の回答を再利用してよいかも確認します。

検索後にモデルへ「他人の情報は答えないで」と指示しても、既に不必要な情報を渡している場合があります。取得する前に範囲を制限し、後段の確認も組み合わせます。

権限の変更と削除を反映する

利用者が部署を移った、文書が非公開になった、といった変更後も古い索引が残る可能性があります。更新の遅れを把握し、重要な場合は実行時の再確認や即時の停止手段を検討します。権限を追加する場合だけでなく、外した場合の確認を行います。

権限のない情報がモデルへ届かないか確認する

最終回答に情報が出なかっただけでは、取得前の境界が守られている証拠にはなりません。権限の異なる利用者へ同じ質問を行い、検索結果としてどの文書が返されたかを制御された条件で確認します。模型的な評価でも、認証情報と検索条件が実際の構成と一致することが必要です。プロンプトだけを変えた試験で、検索側の認可まで確認済みとはしません。

権限を解除した直後、キャッシュした回答や会話の履歴へ古い情報が残る場面も考慮します。検索の新しい結果が正しくても、過去の内容を再表示できるなら別の経路が残ります。どの時点で利用者の権限を再評価し、どの派生物を使えるかを決めます。対象の存在を知らせるだけでも問題になる用途では、拒否や0件の説明にも注意が必要です。

再現例で判断の境界を確認する

三つの架空文書を用意し、営業部、空の権限、別テナントという条件でモデルに渡せる文書IDを比較します。

検索は文字列の包含判定です。Azureや実RAGの接続、認証基盤、権限失効、キャッシュ分離、間接的な情報漏えいを網羅した安全性試験ではありません。

具体例で考える

架空の利用者が部署AからBへ移り、検索側の権限情報だけ古いまま残っています。

説明用の入力・判断例
変更元:文書庫ではAの閲覧を解除。
未反映:索引の利用者属性。
必要:更新の反映確認と、古い範囲で取得させない制約。

正しいログインを行っていても、検索に使う権限が古ければ問題は残ります。認証の成功と、最新の認可条件の適用を区別して評価します。

ローカルで再現する:取得前に部署とテナントを絞る

三つの架空文書を用意し、営業部、空の権限、別テナントという条件でモデルに渡せる文書IDを比較します。

以下は公開コードを実行して得た結果です。入力は学習用に作った架空データで、Claude API・外部サービスへの通信は行っていません。モデルの回答品質や実サービスの性能を測定した結果ではありません。

実行:
Node.js v24.22.1 · darwin/arm64 · 3ケース

再現の手順

  1. 実行コードを保存する(.mjs)
  2. Node.js 20以上の環境で、保存したフォルダから次のコマンドを実行します。追加パッケージやAPIキーは不要です。
  3. 出力の各ケースを読み、入力を一つ変えて判断の違いを確かめてください。
node rag-access-control.mjs

実際の入力と出力

{
  "documents": [
    {
      "id": "sales-plan",
      "tenant": "north",
      "groups": [
        "sales"
      ],
      "text": "計画"
    },
    {
      "id": "hr-plan",
      "tenant": "north",
      "groups": [
        "hr"
      ],
      "text": "計画"
    },
    {
      "id": "other-plan",
      "tenant": "south",
      "groups": [
        "sales"
      ],
      "text": "計画"
    }
  ],
  "cases": [
    {
      "name": "営業グループの検索",
      "principal": {
        "tenant": "north",
        "groups": [
          "sales"
        ]
      },
      "expected": [
        "sales-plan"
      ],
      "actual": [
        "sales-plan"
      ],
      "pass": true
    },
    {
      "name": "権限が空なら結果を渡さない",
      "principal": {
        "tenant": "north",
        "groups": []
      },
      "expected": [],
      "actual": [],
      "pass": true
    },
    {
      "name": "別テナントの同じグループ名",
      "principal": {
        "tenant": "south",
        "groups": [
          "sales"
        ]
      },
      "expected": [
        "other-plan"
      ],
      "actual": [
        "other-plan"
      ],
      "pass": true
    }
  ]
}
実行したコードを読む
// Fictional retrieval records only. No external search or model calls.
export function retrieve(documents,principal,query) {
  if(!principal?.tenant||!Array.isArray(principal.groups)||!principal.groups.length)return [];
  return documents.filter(doc=>doc.tenant===principal.tenant&&doc.groups.some(g=>principal.groups.includes(g))&&doc.text.includes(query)).map(doc=>doc.id);
}
export function run() {
  const documents=[
    {id:'sales-plan',tenant:'north',groups:['sales'],text:'計画'},
    {id:'hr-plan',tenant:'north',groups:['hr'],text:'計画'},
    {id:'other-plan',tenant:'south',groups:['sales'],text:'計画'},
  ];
  const fixtures=[
    {name:'営業グループの検索',principal:{tenant:'north',groups:['sales']},expected:['sales-plan']},
    {name:'権限が空なら結果を渡さない',principal:{tenant:'north',groups:[]},expected:[]},
    {name:'別テナントの同じグループ名',principal:{tenant:'south',groups:['sales']},expected:['other-plan']},
  ];
  return {documents,cases:fixtures.map(c=>{const actual=retrieve(documents,c.principal,'計画');return {...c,actual,pass:JSON.stringify(actual)===JSON.stringify(c.expected)};})};
}
if(process.argv[1]&&import.meta.url===new URL('file://'+process.argv[1]).href)console.log(JSON.stringify(run(),null,2));

コードのSHA-256:2ff88725f9576a7bd7fd8e2a8eb7b8de88285a973eb88173885736667e271a7f

5実験の実行記録を保存する(JSON)

この検証で分かる範囲

検索は文字列の包含判定です。Azureや実RAGの接続、認証基盤、権限失効、キャッシュ分離、間接的な情報漏えいを網羅した安全性試験ではありません。

確認課題と合格の目安

権限の追加、解除、組織変更の三つで検索結果がどう変わるべきかを書いてください。

  • モデルの自己申告へ認可を任せない。
  • 断片と派生した回答も確認している。
  • 権限解除の反映遅れを扱える。