2026-10-21確認。権限の異なる文書をRAGへ載せるとき、検索結果を画面で隠すだけで十分かを、架空データのローカル検証で考えます。
結論と適用条件
検索前に候補を絞り、本文を取得して外部リランカーやLLMへ渡す前に、現在の閲覧権限を確認する構成を検討します。検索の取りこぼしを抑える処理と、失効した権限を拒否する処理には、それぞれ役割があります。
ただし、これは今回の条件に対する設計案です。元文書の権限を検索時にも検証できる既製品で要件を満たせるなら、その仕組みを先に評価します。独自の認可処理を増やすこと自体を目的にはしません。
| 判断 | 条件 |
|---|---|
| PoCへ進む | 利用者をサーバー側で確定でき、文書権限の正本と失効反映の条件が分かる |
| 見送る | 広い権限の共通アカウントで全本文を取得し、出力時に隠す以外の制御がない |
| 追加検証 | 権限同期の遅れ、グループ脱退、キャッシュ失効、検索漏れの許容範囲が未確認 |
この記事のコードは、AI支援で用意・実行した個人ローカル検証です。認証基盤、ベクトル検索、LLM、本文ストレージには接続していません。本番実装・導入実績・業務効果を示すものではありません。
課題は「見せない」より前にある
想定するのは、同じ質問に対しても、所属するテナントと閲覧グループによって使える文書が異なる検索です。ACLは文書の閲覧許可を表すアクセス制御リストとします。
検索で拾った本文をLLMへ送った後に、回答や引用だけを隠しても、本文を送った事実は戻せません。そこで、今回は「どのIDを本文取得の候補として通すか」までを切り出します。
前提は次のように限定します。
- 利用者のテナントとグループは、認証後の信頼できるサーバー処理から渡す。画面や質問文の自己申告を採用しない。
- 文書は単一テナントに属し、許可グループのいずれかに属すれば閲覧可能とする。明示的拒否、階層継承、個人別例外は今回のモデルに含めない。
- 検索索引のACLには同期遅れがある。別に現在のACLを照合できると仮定する。
- 公開文書は扱わず、ACL欠落・空の許可集合は拒否する。
正本の権限規則を単純なグループ一致へ無断で置き換えないことが、実環境へ移す前提です。
選択肢と代替案
| 方式 | 利点 | 採用しない条件・残る問題 |
|---|---|---|
| 現状の文書検索・手動回答を継続 | 元システムの閲覧制御を使う運用を維持できる | 横断検索や回答支援の必要性が、その運用負担を上回るかは別途評価 |
| 権限連携を備えた既製品 | 独自同期・認可コードを減らせる可能性 | 対象コネクタ、権限規則、失効時の保証が要件に合わない |
| 全体top-kを取得してから認可 | 単純で、本文取得前に確実に認可すれば未許可本文の使用を防げる | 上位が未許可文書で埋まり、許可文書を取りこぼす |
| 索引ACLで候補を絞る | 許可候補の中から上位を選べる | 古い索引ACLだけでは、失効した許可が残る |
| 候補の事前絞り込み+現在権限の再照合 | 検索漏れと失効を別の地点で扱える | 正本照合の費用・可用性が必要。権限付与の反映遅れや競合は残る |
索引をテナントごとに分ける方法もあります。ただし、それだけでは同一テナント内の文書別権限までは表現できません。
公式資料で確認できること
Microsoftのsecurity filterパターンは、識別子文字列による検索結果の絞り込みであり、その文字列自体による認証・認可ではないと説明しています。グループIDをフィルタへ入れれば、本人確認まで済むわけではありません。 Security filtersの公式説明
Azure AI Searchのベクトル検索では、preFilterとpostFilterでフィルタの適用位置が異なり、後者は条件が厳しい場合や小さなkで検索漏れが生じ得ます。preFilterにも選択性による計算量・遅延のトレードオフがあります。以下のコードは単一の固定順位を使う説明用モデルで、Azureの分散検索・HNSWの再実装や性能比較ではありません。 Vector query filtersの公式説明
OWASPはデフォルト拒否と、各リクエストでの権限確認を勧めています。以下の「不明なら渡さない」処理は、その原則を今回の架空条件へ適用した設計です。 Authorization Cheat Sheet
固定順位で適用位置を比較する
Python 3.11.8、標準ライブラリのみで実行しました。スコアは架空の順位であり、類似度の実測値ではありません。5文書のうち、対象利用者が通常読めるのはops-guideだけです。
| 順位 | 文書ID | テナント | 許可グループ | 対象利用者の閲覧 |
|---|---|---|---|---|
| 1 | restricted-1 | tenant-a | finance | 不可 |
| 2 | restricted-2 | tenant-a | finance | 不可 |
| 3 | ops-guide | tenant-a | ops | 可 |
| 4 | other-tenant | tenant-b | ops | 不可 |
| 5 | no-acl | tenant-a | 空 | 不可 |
k=2で全体上位を先に選ぶと、許可文書が候補から落ちます。一方、索引の権限で先に絞ると3位を拾えます。ただし、検索後にops-guideの許可が剥奪されていたなら、現在のACLによる再照合が必要です。
次のコードをacl_fixture.pyとして保存し、python3 acl_fixture.pyで実行できます。扱うのは架空のID・権限・順位だけです。
# Fictional, deterministic fixture. No search service, identity provider, or LLM.
from dataclasses import dataclass
import platform
@dataclass(frozen=True)
class Principal:
tenant: str
groups: frozenset[str]
# (tenant, allowed groups); absence means deny. Scores are invented ranks.
INDEX = {
"restricted-1": ("tenant-a", {"finance"}),
"restricted-2": ("tenant-a", {"finance"}),
"ops-guide": ("tenant-a", {"ops"}),
"other-tenant": ("tenant-b", {"ops"}),
"no-acl": ("tenant-a", set()),
}
SCORES = {"restricted-1": 100, "restricted-2": 90, "ops-guide": 80,
"other-tenant": 70, "no-acl": 60}
USER = Principal("tenant-a", frozenset({"ops"}))
def allowed(doc_id, principal, acl):
entry = acl.get(doc_id)
return bool(entry and principal.tenant == entry[0]
and principal.groups.intersection(entry[1]))
def ranked(ids, k):
return sorted(ids, key=lambda doc_id: (-SCORES[doc_id], doc_id))[:k]
def retrieve(principal, current_acl, k=2):
if type(k) is not int or k < 1:
raise ValueError("k must be a positive integer")
if principal is None or not principal.tenant or not principal.groups:
return "denied", []
if current_acl is None:
return "authorization_unavailable", []
candidates = ranked(
[doc_id for doc_id in INDEX if allowed(doc_id, principal, INDEX)], k)
# Recheck the trusted current snapshot before fetching any body.
permitted = [doc_id for doc_id in candidates
if allowed(doc_id, principal, current_acl)]
return ("ready" if permitted else "no_authorized_hit"), permitted
# Baseline: take global top-k, then check the current ACL (safe but incomplete).
post_only = [doc_id for doc_id in ranked(INDEX, 2)
if allowed(doc_id, USER, INDEX)]
assert post_only == []
revoked = {**INDEX, "ops-guide": ("tenant-a", {"finance"})}
granted = {**INDEX, "restricted-1": ("tenant-a", {"ops"})}
cases = [
("normal", USER, INDEX, ("ready", ["ops-guide"])),
("revoked", USER, revoked, ("no_authorized_hit", [])),
("other_tenant", Principal("tenant-c", frozenset({"ops"})), INDEX,
("no_authorized_hit", [])),
("no_groups", Principal("tenant-a", frozenset()), INDEX, ("denied", [])),
("no_identity", None, INDEX, ("denied", [])),
("missing_current_acl", USER, {}, ("no_authorized_hit", [])),
("acl_unavailable", USER, None, ("authorization_unavailable", [])),
# Newly granted restricted-1 is still excluded by the stale index ACL.
("grant_not_indexed", USER, granted, ("ready", ["ops-guide"])),
]
print("Python", platform.python_version())
print("post_only:", post_only)
for name, principal, acl, expected in cases:
actual = retrieve(principal, acl)
assert actual == expected, (name, actual, expected)
print(name + ":", *actual)
try:
retrieve(USER, INDEX, k=0)
except ValueError:
print("invalid_k: rejected")
else:
raise AssertionError("invalid k was accepted")
print("PASS: baseline + 8 scenarios + invalid k")
実行結果は次のとおりでした。
Python 3.11.8
post_only: []
normal: ready ['ops-guide']
revoked: no_authorized_hit []
other_tenant: no_authorized_hit []
no_groups: denied []
no_identity: denied []
missing_current_acl: no_authorized_hit []
acl_unavailable: authorization_unavailable []
grant_not_indexed: ready ['ops-guide']
invalid_k: rejected
PASS: baseline + 8 scenarios + invalid k
比較用のpost_onlyは空でした。事前絞り込みを使うnormalではops-guideを返し、revokedでは失効後の文書を返しませんでした。認可サービスの利用不可を表すNoneは、単なる検索0件とは違う状態にしています。
grant_not_indexedにも意味があります。正本ではrestricted-1を新たに許可していても、古い索引で先に除外したため、その文書は戻りません。再照合は候補の失効を除外できても、検索前に落とした新規許可文書を復活させません。 索引ACLの更新は引き続き必要です。
このコードで確認していないこと
readyは本文取得候補のIDが得られた状態です。回答の正しさや、本番での送信許可までは意味しません。
Principalとcurrent_aclは信頼済みの固定入力です。本人確認、グループ脱退の反映、ACL取得中の例外・タイムアウト、正本と本文取得の間に権限が変わる競合は再現していません。Noneは障害状態の模擬であり、通信障害試験ではありません。固定入力の型・値の検証も本番の入力検証の代わりにはなりません。
この小さな検証だけで、本番のアクセス制御が安全だとは判断できません。
本番化するときに残る境界
次の図は実装済みの構成ではなく、今回の検証を踏まえた設計案です。
チャンク化する場合は、すべてのチャンクに親文書IDとテナント、権限情報を対応させます。再照合も親文書の正本へ戻して行います。隣接チャンクの補完、検索以外のID指定取得、引用リンクが別経路になっていないかも確認します。
失敗時・中断時を決める
| 状態 | システム側の扱いとして提案すること | 次の対応 |
|---|---|---|
| 認証情報がない・所属を確定できない | 本文取得へ進めない | ログイン・所属確認へ戻す |
| 現在ACLで不許可・ACLがない | 対象候補を除外し、本文を渡さない | 権限の正本と同期を確認 |
| 認可サービスが利用不可 | 検索0件と区別して保留。古い許可へ無条件にフォールバックしない | 復旧後に現在権限からやり直す |
| 利用者が処理を中断 | 後続の本文取得・LLM送信を止める | 既に送ったデータは取消済みと扱わず、送信段階を記録 |
| 照合後に権限が変わる | 許容する失効遅延と整合性の契約を決める | 権限版検証や失効通知を含め、実環境で競合試験 |
人へ戻す場合も、その担当者が文書を読める必要があります。管理者へ全文を転送することを、自動的な代替処理にはしません。
キャッシュ・ログは別の出口になる
検索質問だけをキーにした回答キャッシュを共有すると、別利用者へ以前の回答を返す経路が生まれます。初期PoCでは権限付き回答の共有キャッシュを無効にし、必要になってから権限スコープ、依存文書、権限版、失効時の無効化を設計する案が考えられます。利用者別キーだけでも、同じ利用者の権限剥奪後の再利用は解決しません。
ログにも本文や拒否文書のタイトルを安易に残さず、許可された相関ID、判定段階、理由、権限版、時刻を必要な範囲で記録します。一般利用者には「その文書は存在するが権限がない」といった存在情報を無用に返さない表示を検討します。これらは今回のコードでは未実装です。
費用と責任分担
独自実装の費用は検索やLLMの利用料だけでは決まりません。権限同期、正本照合、障害監視、失効時の調査も必要です。現在ACLの照合を候補ごとに通信すると負荷が増えるため、バッチ照合の有無も比較します。今回、実サービスの価格・遅延・工数・削減効果は測定していません。
文書管理担当は権限の正本と例外規則、認証担当は本人・所属の確定と脱退反映、検索担当は全検索経路への条件適用と同期、アプリ担当は本文・引用・履歴・キャッシュの出口を確認する、と責務を整理できます。正本が停止したときの保留・復旧判断を誰が行うかも、PoC前に決めます。
条件付きの推奨と次の確認
今回の架空条件では、候補の事前絞り込みと、本文取得前の現在権限照合を組み合わせる案を次の検証対象とします。理由は、検索漏れと失効が別の失敗だからです。
既製品で元文書の権限を継承できるなら、まず同じ条件で評価します。独自実装を選ぶ場合も、次の検証を満たすまでは対象文書を限定し、権限を確認できないときに回答を続ける運用へ進めません。
- 実際の権限規則を反映した期待結果表を作る。テナント境界、個人例外、継承、拒否の優先順位を含める。
- グループ脱退・文書の許可剥奪・新規付与について、索引と利用者情報の反映遅延を測る。
- 認可基盤停止と、照合直後の権限変更を起こし、本文・外部送信・ログに何が残るか確認する。
-
実検索で許可集合内の検索漏れと遅延を測る。漏れ対策として
kを増やしても認可を省略しない。 - 引用、会話履歴、キャッシュ、隣接チャンク補完、ID指定取得を同じ権限試験へ含める。
5件の固定文書で確認できたのは、境界の違いが結果へ現れることです。どの製品・構成を採用するかは、この追加検証と運用担当の合意を経て判断します。
参考資料
以下は2026-10-21に確認しました。公式仕様の説明と、この記事独自の架空検証・設計案を分けて参照してください。
- Microsoft Learn: Security filters for trimming results in Azure AI Search — フィルタ文字列と認証・認可の区別。
- Microsoft Learn: Vector query filters — フィルタ適用位置と検索漏れ・計算量の関係。
- OWASP: Authorization Cheat Sheet — デフォルト拒否、リクエストごとの権限確認。
この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表。AI × Web × 業務自動化の開発・検証に取り組んでいます。
技術選定・品質・運用の判断材料を、検証範囲と制約を示して発信しています。
AI導入や業務自動化の技術選定・設計整理については、対応できる範囲を確認のうえご相談を承ります。
