権限対応のグローバル検索設計(データ漏洩を防ぐ)
高速なインデックスとレコード単位の厳格なアクセスチェックで、権限対応のグローバル検索を設計し、情報漏洩を防ぐ方法を学びます。

なぜグローバル検索でデータ漏洩が起きるのか
グローバル検索とは、アプリ全体を横断するひとつの検索ボックスで、顧客、チケット、請求書、ドキュメント、ユーザーなどを探せるものです。オートコンプリートやクイック結果ページでレコードにすばやく移動できるようにします。
漏洩が起きるのは、ユーザーが開けないはずのものを検索結果が存在を示してしまうときです。たとえレコードを開けなくても、タイトル、人名、タグ、ハイライトされたスニペットなどの一行が機密情報を明かすことがあります。
検索は「閲覧のみ」に見えるため、軽く扱われがちです。しかし、結果タイトルやプレビュー、オートコンプリートの提案、結果数、ファセット(例:「Customers (5)」)や処理時間の差などを通じてデータが露出します。
この問題は初期段階では起きにくいことが多いです。最初はロールが一つしかなかったり、テスト用データベースですべて見える状態で出荷しがちです。プロダクトが成長するとサポートと営業、マネージャーとエージェントのようにロールが増え、共有受信箱や非公開メモ、制限された顧客、「自分の担当のみ」といった機能が追加されます。検索が古い前提に依存したままだと、チーム間や顧客間のヒントを返してしまいます。
よくある失敗は、速度優先で「すべてを」インデックスしておき、検索後にアプリ側でフィルタすることです。それでは遅すぎます。検索エンジンは既にマッチを決めており、提案やカウント、部分フィールドを通じて制限されたレコードを露出する可能性があります。
たとえば、あるサポート担当者が自分の担当顧客のチケットだけを見る権限しか持っていないとします。「Acme」と入力してオートコンプリートが「Acme - 法務エスカレーション」や「Acme 漏洩通知」を表示したら、それだけでデータ漏洩です。クリックして「アクセス拒否」になったとしても、タイトルだけで情報が漏れています。
権限対応のグローバル検索の目標は簡単に言えますが実装は難しいです:高速かつ関連性の高い結果を返しつつ、レコードを開くときに適用するのと同じアクセスルールを必ず守ること。すべてのクエリは、ユーザーが自分の見てよいデータの範囲しか見られないかのように振る舞わなければならず、UIはカウントなどの余分な手がかりを漏らさない必要があります。
何をインデックスし、何を守るべきか
グローバル検索は単純に見えますが、ユーザーが単語を入力して答えを期待する裏側には、新たなデータ露出の表面が生まれます。インデックスやデータベース機能を選ぶ前に、まず二つを明確にしてください:どのオブジェクト(エンティティ)を検索対象にするか、そしてそのオブジェクトのどの部分が機密か。
エンティティとはユーザーが素早く見つけたい任意のレコードです。ビジネスアプリでは顧客、サポートチケット、請求書、注文、ファイル(メタデータ)などが含まれます。人に関するレコード(ユーザー、エージェント)、内部メモ、統合やAPIキーのようなシステムオブジェクトも対象になることがあります。名前、ID、ステータスなど誰かが入力しそうなものは検索に載りがちです。
テーブル単位ルールとレコード単位ルールの違い
テーブル単位ルールは大雑把です:テーブル全体にアクセスできるか、できないか。例:請求書ページはFinanceだけが開ける。これは分かりやすいですが、同じテーブル内で人ごとに見える行が異なる場合には破綻します。
レコード単位ルールは行ごとの可視性を決めます。例:サポートエージェントは自分のチームに割り当てられたチケットだけ見られ、マネージャーは自分の地域のすべてのチケットを見られる。多くのマルチテナントアプリでは、ユーザーは customer_id = their_customer_id のような条件でしかレコードを見られません。
検索が漏らすのは通常、これらレコード単位ルールです。インデックスがヒットを返してから行アクセスをチェックすると、既に存在が露見しています。
「見てよい」とは実際に何を意味するか
「見てよい」は単純なyes/noではないことが多く、所有権(自分が作成、または割当)、メンバーシップ(自分のチーム、部門、ロール)、スコープ(地域、事業部、プロジェクト)、レコード状態(公開、非アーカイブ)、特別扱い(VIP顧客、法的保留、制限タグ)などが組み合わさります。
まずはこれらのルールを平易な言葉で書き出してください。後でそれをデータモデルとサーバーサイドのチェックに落とし込みます。
結果プレビューで何を安全に見せるか決める
検索結果に含まれるプレビューのスニペットでも、ユーザーがレコードを開けなくても機密情報が漏れることがあります。
安全なデフォルトは、アクセスが確認されるまで最小限で非機密のフィールドだけを表示することです:表示名またはタイトル(場合によってはマスク)、短い識別子(注文番号など)、高レベルのステータス(Open、Paid、Shipped)、日付(作成・更新)、一般的なエンティティラベル(Ticket、Invoice)など。
具体例:誰かが "Acme merger" と検索して制限されたチケットがある場合、"Ticket: Acme merger draft - Legal" を返すのは既に漏洩です。より安全な表示は "Ticket: Restricted" のようにスニペットを出さないか、ポリシー次第では結果自体を非表示にすることです。
これらの定義を最初にきちんと決めておくと、何をインデックスするか、どのようにフィルタするか、どこまで公開するかの判断が後で簡単になります。
安全かつ高速な検索に必要な基本要件
ユーザーは急いでいるときにグローバル検索を使います。1秒以上かかると信頼を失い手動フィルタに戻ることが多いです。しかし速度は半分にすぎません。高速でも一つでもレコードタイトルや顧客名、チケット件名を漏らす検索は、ない方がましです。
コアのルールは譲れません:権限はクエリ実行時に強制すること。取得した後で行を隠すのは遅すぎます。システムは既に触れてしまっているからです。
同じことは検索周辺にも当てはまります。提案、トップヒット、カウント、"結果なし" の挙動までが情報を漏らし得ます。オートコンプリートが開けないはずの "Acme Renewal Contract" を表示するのは漏洩です。ファセットで "12 matching invoices" と出すのは、ユーザーが見るべきは3件だけなのに存在を示す漏洩になります。クエリが制限されたマッチで遅くなるなどタイミング差も情報になります。
安全なグローバル検索に必要な四点:
- 正確性:返されるアイテムは全て、そのユーザー、テナントに対して今見てよいものだけであること。
- 速度:大規模でも結果、提案、カウントが一貫して速いこと。
- 一貫性:アクセスが変わったとき(ロール更新、チケット再割当など)に検索挙動が速やかに予測可能に変わること。
- 監査可能性:なぜそのアイテムが返ったのか説明でき、捜査に備えて検索アクティビティをログに残せること。
マインドセットの切り替え:検索をUI機能ではなく別のデータAPIとして扱ってください。つまり一覧ページに適用するのと同じアクセスルールを、インデックス構築、クエリ実行、関連するすべてのエンドポイント(オートコンプリート、最近の検索、人気クエリ)に適用するということです。
よく使われる三つの設計パターン(使いどころ)
検索ボックス自体は作るのは簡単ですが、権限対応のグローバル検索は難しいです。インデックスは瞬時に結果を返したがり、アプリはユーザーがアクセスできないレコードを間接的にも漏らしてはならないからです。
以下はよく使われる三つのパターンです。どれを選ぶかはアクセスルールの複雑さと許容できるリスクに依存します。
アプローチA:安全なフィールドのみをインデックスし、クリック時に権限チェックで取得する。 検索インデックスにはIDと誰にでも見せられる非機密ラベルなど最小限のドキュメントを置きます。ユーザーが結果をクリックしたら、アプリがプライマリDBから完全なレコードを読み込み、本当の権限ルールを適用します。
これにより漏洩リスクは下がりますが、結果が薄く感じられることがあります。また「安全な」ラベルが誤って秘密を示さないよう注意が必要です。
アプローチB:インデックスに権限属性を保存し、そこでフィルタする。 各ドキュメントに tenant_id、team_id、owner_id、ロールフラグ、project_id のようなフィールドを含めます。各クエリは現在のユーザーのスコープに合うフィルタを追加します。
これによりリッチで高速な結果やオートコンプリートが得られますが、権限がフィルタで表現できる場合に限ります。権限が複雑な論理(例:「割当 OR 今週オンコール OR インシデントの一部」)だと正確性を保つのが難しくなります。
アプローチC:ハイブリッド。インデックスで大まかにフィルタし、最終チェックをDBで行う。 インデックスではテナントやワークスペース、顧客のような安定した広い属性で絞り、候補IDの小さい集合に対してプライマリDBで最終的な権限チェックを行ってから返します。
実際のアプリではこれが最も安全な道であることが多いです:インデックスは速く、DBが真の情報源(source of truth)であり続けます。
パターンの選び方
最小限のスニペットで我慢できるならAを選びます。明確でほとんど静的なスコープ(マルチテナント、チームベースのアクセス)があり非常に高速なオートコンプリートが必要ならBを選びます。多数のロール、例外、頻繁に変わるレコード固有ルールがあるならCを選びます。HR、財務、医療のような高リスクデータにはCを推奨します。「ほぼ正しい」では許されないからです。
権限ルールを守るインデックス設計の手順
まず、新しいチームメンバーに説明するような平易な言葉でアクセスルールを書いてください。"管理者はすべて見られる" と安易に書かないで、本当にそうかを確認し、理由を書きます:"サポートエージェントは自分のテナントのチケットを見られる。チームリードは組織単位のチケットも見られる。プライベートノートはチケットの所有者と割当エージェントのみが見られる。" こうした理由がないと安全にエンコードするのが難しくなります。
次に、安定した識別子を選び、最小限の検索ドキュメントを定義します。インデックスはDB行の完全なコピーであるべきではありません。結果リストで見つけやすく表示するために必要な項目だけを保持します(タイトル、ステータス、短い非機密スニペットなど)。機密フィールドは権限チェックを行う第二フェッチの背後に置きます。
次に、素早くフィルタできる権限信号を決めます。これらは各インデックスドキュメントに保存できるアクセスゲートになる属性です(tenant_id、org_unit_id、少数のスコープフラグなど)。目標は、オートコンプリートを含めてすべてのクエリが返す前にフィルタを適用できることです。
実用的なワークフローは次の通りです:
- 各エンティティ(チケット、顧客、請求書)について可視性ルールを平易な言葉で定義する。
- record_id と安全かつ検索に必要な最小フィールドで検索ドキュメントスキーマを作る。
- tenant_id、org_unit_id、visibility_level のようなフィルタ可能な権限フィールドを各ドキュメントに追加する。
- 例外は明示的な許可リストで扱う:共有アイテムのためのユーザーIDのallowlistやグループIDを保存する。
共有アイテムと例外が設計を壊すポイントです。チケットがチーム間で共有されうるなら単にブール値を追加するのではなく、フィルタでチェックできる明示的な許可リストを使ってください。allowlist が大きいなら個人ユーザーよりグループベースの付与を優先します。
インデックスを予期せぬずれなく同期させる
安全な検索体験は地味だが重要なことに依存します:インデックスは現実を反映していなければなりません。レコードが作成・変更・削除されたり、権限が変わったときに検索結果が速やかに一貫して変わることが必要です。
作成・更新・削除に追いつく
インデックスをデータライフサイクルの一部として扱いましょう。便利な考え方は:真の情報源が変わるたびにイベントを発行し、インデクサーがそれに反応する、というものです。
データベーストリガー、アプリケーションイベント、ジョブキューなどがよく使われます。重要なのはイベントが失われないことです。レコードは保存されたがインデックスに反映されないと、"存在は知っているのに検索で見つからない" といった混乱が起きます。
権限変更もインデックス変更である
多くの漏洩はコンテンツ自体は更新されるがアクセスメタデータが更新されないときに起きます。ロール更新、チーム移動、所有権移転、顧客再割当、チケットのマージが原因になります。
権限の変更をファーストクラスのイベントにしてください。権限対応の検索が tenant や team のフィルタに依存しているなら、インデックスドキュメントがそれらのフィールド(tenant_id、team_id、owner_id、allowed_role_ids)を含むことを保証し、それらが更新されたら再インデックスしてください。
難しいのは影響範囲の大きさです。ロール変更が何千件ものレコードに影響することがあります。進捗、再試行、停止手段を備えたバルクリインデックスの設計を計画してください。
最終的整合性を見越した設計
良いイベント駆動でも、検索が遅れるウィンドウは存在します。変更後数秒間ユーザーに何を見せるかを決めておきましょう。
二つのルールが助けになります:
- 遅延に一貫性を持たせる。通常インデックスが2〜5秒で終わるなら、その期待値を重要時に伝える。
- 漏らすより欠ける方が安全。権限が付与された新しいレコードが少し遅れて表示される方が、取り消されたレコードが表示され続けるより安全です。
インデックスが古くても安全なフォールバックを用意する
検索は発見のためのもので、詳細表示で漏洩が問題になります。結果がインデックスの遅延で漏れた場合に備え、詳細を表示する前に二次的な権限チェックを行ってください。もし結果が通ってしまっても、詳細ページがアクセスをブロックすればダメージは抑えられます。
良いパターンは:検索では最小限のスニペットを表示し、レコードを開く/プレビュー拡大する際に権限を再チェックすること。チェックに失敗したら明確なメッセージを出し、次回の更新でそのアイテムを結果セットから削除します。
データ漏洩を引き起こすよくある間違い
検索は「レコードを開くページ」がロックダウンされていても情報を漏らします。ユーザーが結果をクリックしなくても、名前、顧客ID、あるいは隠されたプロジェクトの規模を知ることがあります。権限対応のグローバル検索はドキュメント自体だけでなく、ドキュメントに関するヒントも守る必要があります。
オートコンプリートは頻繁に漏洩源になります。提案は高速なプレフィックス検索で動くことが多く、完全な権限チェックを飛ばすことがあるからです。UIは無害に見えますが、入力一文字で顧客名や従業員のメールが露出することがあります。オートコンプリートはフル検索と同じアクセスフィルタを実行するか、あらかじめフィルタされた提案集合(テナント・ロールごと)から構築するべきです。
ファセットのカウントや "About 1,243 results" のようなバナーも静かな漏洩です。カウントは何かが存在することを確認してしまいます。同じアクセスルールで安全にカウントできないなら、詳細を減らすかカウントを省略してください。
キャッシュもよくある原因です。ユーザー・ロール・テナントをまたぐ共有キャッシュは、あるユーザーが別のユーザーのために生成された結果を見てしまう "結果の幽霊" を生みます。エッジキャッシュ、アプリケーションキャッシュ、検索サービス内のインメモリキャッシュで起こります。
早めにチェックすべき漏れの罠:
- オートコンプリートと最近の検索はフル検索と同じルールでフィルタされているか。
- ファセットのカウントや合計は権限適用後に計算されているか。
- キャッシュキーに tenant ID と権限署名(ロール、チーム、ユーザーID)が含まれているか。
- ログや分析に制限データの生クエリやスニペットを保存していないか。
最後に、過度に広いフィルタに注意してください。"テナントでフィルタするだけ" は古典的なミスですが、テナント内でも同様の誤りが起きます:たとえば "部門でフィルタ" しているがアクセスはレコードごとに異なるケース。解決は原則的に簡単です:検索、オートコンプリート、ファセット、エクスポートなど、すべてのクエリ経路で行レベルルールを強制することです。
忘れられがちなプライバシーとセキュリティの細部
多くの設計は "誰が何を見られるか" に集中しますが、漏洩は空状態、タイミング、UIの小さなヒントを通じても起きます。権限対応検索は結果が何も返さない場合でも安全である必要があります。
一つ簡単な漏洩は存在の確認です。権限のないユーザーが特定の顧客名、チケットID、メールを検索して "No access" や "You don't have permission" といった特別なメッセージを受け取ると、その存在を確認してしまいます。"存在しない" と "あるが許可されていない" を区別しない "結果なし" を既定の応答にしてください。応答時間や文言を一貫させ、速度で推測されないようにします。
機密な部分一致
オートコンプリートや検索途中での検索はプライバシーが崩れる場です。メール、電話番号、政府発行のIDや顧客IDの部分一致は意図せぬ露出を招きます。これらのフィールドをどう扱うかを事前に決めてください。
実用的なルール例:
- 高リスクフィールド(メール、電話、ID)は完全一致を要求する。
- 隠れたテキストを明らかにするハイライトスニペットは避ける。
- 機密フィールドにはオートコンプリートを無効にすることを検討する。
たとえ一文字でも推測に使えるなら、それを機密扱いしてください。
悪用対策(ただし新たなリスクを作らない)
検索エンドポイントは列挙攻撃に最適な場所です。多数のクエリで何が存在するかを探られる可能性があります。レートリミットや異常検知を追加してください。ただしログに生クエリを残すと第二の漏洩源になるので注意が必要です。
シンプルに:ユーザー単位、IP単位、テナント単位でレート制限を設け、ログには件数、応答時間、粗いパターンを残す(生クエリは残さない)、近接ミス(連番IDなど)の繰り返しがあればアラートや追加検証を行う、繰り返し失敗したらブロックまたは検証強化する。
エラーメッセージは地味にする:"結果なし"、"許可されていない"、"無効なフィルタ" を同じ形で返すほど推測されにくくなります。検索UIが喋るほど誤って情報を与えてしまうリスクが上がります。
例:サポートチームが顧客をまたいでチケットを検索する場合
サポート担当のMayaは3つの顧客アカウントを担当するチームに所属しており、アプリのヘッダーに検索ボックスがあります。プロダクトはチケット、連絡先、会社を横断するグローバルインデックスを持ちますが、すべての結果はアクセスルールを守らねばなりません。
Mayaは通話相手の名前が Alice だと言うので "Alic" とタイプします。オートコンプリートが候補を素早く出します。彼女は "Alice Nguyen - Ticket: Password reset" をクリックしますが、開く前にアプリはそのレコードのアクセスを再チェックします。もしチケットがまだ彼女のチームに割り当てられていて、彼女のロールが許可していれば、チケット画面に移動します。
各段階でMayaが見るもの:
- 検索ボックス:候補は素早く出るが、今アクセスできるレコードだけが表示される。
- 結果一覧:チケット件名、顧客名、最終更新時刻。"アクセス権がありません" のようなプレースホルダは出ない。
- チケット詳細:フルビューはサーバーサイドの権限再チェックの後に読み込まれる。アクセスが変わっていれば "Ticket not found"("forbidden"ではない)を表示する。
同じクエリを打つトレーニング中の新しいエージェントLeoの場合、彼は "Public to Support" にマークされた、その1顧客に限定されたチケットしか見られません。Leoは同じ "Alic" を検索しても候補はずっと少なく、欠けている候補を匂わせるような "5件の結果" の表示はありません。UIは彼が開けるものだけをそのまま見せます。
後にマネージャーが "Alice Nguyen - Password reset" をMayaのチームから専門のエスカレーションチームに再割当したとします。短いウィンドウ(通常は数秒〜数分、同期方法による)でMayaの検索はそのチケットを返さなくなります。もし彼女が詳細ページを開いていてリフレッシュすれば、アプリは権限を再チェックし、チケットは消えます。
これが望ましい挙動です:入力と結果取得が速く、カウントやスニペット、古いインデックスエントリから情報の匂いが漏れないこと。
実装のためのチェックリストと次の一手
権限対応のグローバル検索は、地味な端部が安全になって初めて「完了」と言えます。多くの漏洩は無害に見える箇所(オートコンプリート、結果数、エクスポート)で起きます。
事前の簡単な安全チェック
出荷前に実データで次を確認してください:
- オートコンプリート:ユーザーが開けないタイトル、名前、IDを決して提案しない。
- カウントとファセット:合計は権限適用後に計算する(できないなら省略)。
- エクスポートと一括操作:"現在の検索" をエクスポートする場合はエクスポート時に行ごとにアクセスを再チェックする。
- ソートとハイライト:ユーザーが見られないフィールドでソートやハイライトを行わない。
- "Not found" と "forbidden":機密エンティティでは同じ応答形にして存在確認に使えないようにする。
実行可能なテストプラン
小さなロール行列(ロール×エンティティ)と、共有レコード、最近取り消されたアクセス、テナント間で紛らわしいデータを含むデータセットを作ります。
3つのフェーズでテストします:
- ロール行列テスト:拒否されたレコードが結果、提案、カウント、エクスポートに一切出ないことを検証する。
- "壊してみる" テスト:IDを貼り付ける、メールや電話で検索する、部分一致で何も返さないか試す。
- タイミングとキャッシュのテスト:権限を変更して結果が速やかに更新され、古い提案が残らないことを確認する。
運用面では、"検索結果が間違って見える日" に備えて、クエリコンテキスト(ユーザー、ロール、テナント)と適用した権限フィルタをログに残しますが、生の機密クエリやスニペットは保存しないでください。安全にデバッグするための管理者専用ツールを作り、なぜレコードがマッチしたのか、なぜ許可されたのかを説明できるようにすると良いでしょう。
もし AppMaster (appmaster.io) 上で構築しているなら、実務的な方法は検索をサーバー側フローに保つことです:Data Designer でエンティティとリレーションをモデル化し、Business Processes でアクセスルールを強制し、オートコンプリート、結果リスト、エクスポートに同じ権限チェックを再利用して一箇所で正しく動かすことです。


