Alice が alpha に所属するとします。/workspaces/alpha/documents/doc-b/ を要求すると beta の文書が返ります。Alice の認証も所属確認も成功しています。どこで誤ったのでしょうか。

文書と人物は架空です。実行例は Node.js 22 以降ですが、読解方法は他の言語やフレームワークにも使えます。問いは「どのワークスペースを確認し、どのワークスペースが文書検索を制限したか」です。

一つのリクエストと必要な規則から始める

メソッド、パス、主体、期待結果をまとめます。

GET /workspaces/alpha/documents/doc-b/
Demo identity: alice, a member of alpha only
Stored document: doc-b belongs to beta
Expected: 404, with no document in the response
Broken result: 200, containing Beta release notes

この例ではメンバーは所属ワークスペース内の全文書にアクセスでき、文書個別の共有や管理者例外はありません。alpha のメンバーに対して、alpha 外の文書は存在しない文書と同じステータスと本文を返します。未所属のワークスペースなら、ストアを呼ぶ前に 403 です。

追う関係は二つです。認証されたユーザーが URL のワークスペースに所属すること、返す文書もそのワークスペースに属すること。最初が成功しても、二つ目は検索で保証する必要があります。

実報告ではアプリのビルドやソース版も記録します。手元のルートが実際に処理したコードと一致する必要があります。ローカル再現には合成 fixture を使います。

ハンドラーより先にルート登録を探す

request-flow.mjs と request-flow.test.mjs を同じフォルダーに置き、エディターで workspaces や documents を検索します。ripgrep があれば、次で調べる場所を絞れます。

rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs

createApp のルートオブジェクトにはメソッド、パターン、ミドルウェア、選択するハンドラーが並びます。

const route = {
  method: 'GET',
  pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
  middleware: [authenticate, requireWorkspaceMember],
  handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};

後続行も読みます。handle は new URL(request.url).pathname とメソッドを確認し、捕捉した二つの部分を workspaceId と documentId に入れます。クエリからは取得しません。小文字、数字、ハイフンを受け付け、末尾のスラッシュを要求する、汎用ではないルーターです。

フレームワークでは /workspaces/:workspaceId の親マウントと /documents/:documentId の子ルートに分かれる場合があります。親の値の渡し方も確認します。ハンドラーの検索結果だけでは、それを囲むミドルウェアは分かりません。

実際の実行順でミドルウェアを読む

runMiddleware は reduceRight でハンドラーを包みます。[authenticate, requireWorkspaceMember] なら認証、所属、ハンドラーの順に、各段階が next() を呼ぶと進みます。return next() の値は handle に戻り、その catch が HttpError を応答にします。

Request → path and method match → authenticate
        → requireWorkspaceMember → document lookup → JSON Response
                                   ↘ thrown error → handle's catch → error Response

authenticate は Authorization ヘッダーを固定 map で探し、ctx.user に置きます。demo-alice と demo-bob は検証済みセッションの代わりになる公開の fixture 入力です。この例の主体の出所は追えますが、実アプリのセッションやトークン検証は別に読む必要があります。

所属確認を開きます。

async function requireWorkspaceMember(ctx, next) {
  const { workspaceId } = ctx.params;
  const allowed = ctx.user.workspaceIds.includes(workspaceId);
  ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
  if (!allowed) throw new HttpError(403, 'Forbidden');
  return next();
}

Alice と alpha なら allowed は true です。この関数は documentId を調べず、文書も読みません。「Alice は alpha のルートに入れる」と正確に記録します。「認可済み」だけでは、実際以上の意味を読み込みがちです。

止まる場所も記録します。主体がないか不明なら所属確認前に 401、非メンバーなら文書ハンドラー前に 403 です。判断前に next() を呼ぶ実装ではハンドラーがすでに動いている可能性があるため、名前だけでなく呼び出し位置も重要です。

ユーザー、要求先、保存された文書を区別する

失敗リクエストのメモは四行で整理できます。

値 取得元 今回の値 追う先
ユーザーと所属 authenticate が fixture の map を読む Alice、['alpha'] requireWorkspaceMember
要求ワークスペース 最初のルート捕捉 alpha 所属確認、検索引数
要求文書 二つ目の捕捉 doc-b 文書検索
文書の実際の所属 保存レコード beta 検索述語と応答

各生成元と利用側を読みながらファイルと行を加えます。大きなアプリでは型やパッケージが別でも、URL は要求、主体は所属、レコードは所有先を示すという区別が役立ちます。

二つの ID は一緒に届きました。ハンドラーからも一緒に出るか確認します。

普通に見えるハンドラーでも検索本体を開く

意図的に危険なハンドラーです。

async function getDocumentUnsafe(ctx) {
  ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
  const document = await ctx.store.findById(ctx.params.documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

ストアに届くのは documentId だけです。findById を createStore まで追うと、述語は document.id === documentId で、ワークスペースの比較もシリアライズ前の所有確認もありません。前段は alpha を許可しましたが、検索はどの所属の行でも返せます。

競合も偽セッションも不要です。Alice は自分の主体と許可された alpha のまま、fixture の doc-b を指定します。確認した範囲と読む範囲が違うのです。

実プロジェクトでは名前だけは弱い根拠です。メソッド内のテナント条件、スコープ付き DB セッション、行レベルのポリシーがあるかもしれません。引数の省略から無制限と結論する前に、選ばれた実装と設定を読みます。この例ではメモリー内の述語が検索全体なので直接確認できます。

ハンドラー全体に反例を通す

二つのファイルを置いたフォルダーで実行します。

node request-flow.mjs
node --test request-flow.test.mjs

導入、アカウント、サービスは不要です。makeRequest は Web Request を作り、テストがアプリの handle に渡し、Web Response を受け取ります。example.invalid はメモリー内で解析するだけで、fetch や待受ソケットは使いません。

デモは次の応答と、その下に通った段階とストア呼び出しを表示します。

normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}

問題の要求が通る順序です。

request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200

trace 配列は fixture の計測用で、応答には含めません。テストは返された文書と findById('doc-b') の記録も調べ、分岐の順序を実データにつなげます。

reproduce: は危険な版が beta の文書を返すと成功します。これは欠陥を再現できたことを示します。別のテストで同じ要求に修正版が 404 を返すことを要求します。

確認したワークスペースを検索に渡す

修正版はルートの両方の値を保持します。

async function getDocument(ctx) {
  const { workspaceId, documentId } = ctx.params;
  ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
  const document = await ctx.store.findInWorkspace(workspaceId, documentId);
  if (!document) throw new HttpError(404, 'Not found');
  return json(200, document);
}

findInWorkspace は document.workspaceId === workspaceId と document.id === documentId の両方を要求します。同じ workspaceId を所属ミドルウェアが確認済みです。fixture では途中で ctx.params を変更せず、文書と所属も実行中は固定です。

/workspaces/alpha/documents/doc-b/ は一致しなくなり、欠落 ID も別所属の ID も 404 と {"error":"Not found"} を返します。同じステータスと本文にすることで、どちらかを明示的には漏らしません。時間などのサイドチャネルはテストしていません。

全体から読んだ後で document.workspaceId を確認しても、この応答漏えいは止められます。範囲付き検索ならストア呼び出しで制限を明示し、このハンドラーへ別所属の行を返しません。ただしメモリー内の Array.find は述語の評価で行を調べます。記録が示すのは引数で、DB I/O 回数や物理的な隔離ではありません。

SQL や ORM に適用するなら、生成条件とパラメーター束縛を調べ、実データ層で試します。所属取り消し、文書移動、共有文書、別ルートにはそれぞれの規則と確認が必要です。

成功経路と、停止すべき箇所を確認する

10 件のテストは意図的な再現一つと修正版九つです。複数入力を扱うテストもあります。

要求・条件 応答 ストアの確認
Alice が alpha/doc-a、Bob が beta/doc-b 200、正しい文書 両 ID が findInWorkspace に届く
Alice が alpha/doc-b 404、文書なし alpha 内で検索
Alice が alpha/missing 前行と同じ状態と本文 一致なし
主体が欠落・不明 401 呼び出しなし、所属確認にも未到達
Alice が beta/doc-b 403 呼び出しなし
Alice が alpha/doc-b?workspaceId=beta 404 クエリでルートの範囲を上書きできない
不明なパス・既知パスで不正メソッド 404・405 と Allow: GET 呼び出しなし
ストアが予期しないエラーを投げる 500、汎用の本文 応答ラッパーまで伝わる

表の略記は /workspaces/{workspaceId}/documents/{documentId}/ に展開し、クエリは末尾スラッシュの後です。成功二例で全拒否の「修正」を防ぎ、無呼び出しの確認でストアを読んでから拒否する実装を検知します。最終ステータスだけより多くを確認しています。

範囲はプロセス内の処理、ミドルウェア順序、検索引数、JSON 応答です。HTTP 通信、リバースプロキシ、本番認証、DB は対象外です。実アプリでは実際の HTTP アダプターから始め、追ったハンドラーとミドルウェアが選ばれることを確認します。

他の人が検証できるメモを残す

要求と、値を使う正確な場所を中心に結果を書きます。

Trigger: Alice + GET /workspaces/alpha/documents/doc-b/
Route: createApp captures alpha and doc-b.
Membership: requireWorkspaceMember allows Alice for alpha.
Old read: getDocumentUnsafe passes only doc-b to findById.
Observed: 200 returns a row whose workspaceId is beta.
Fix: getDocument passes alpha and doc-b to findInWorkspace.
Regression: same request returns 404; both valid member reads still return 200.
Scope: in-memory request pipeline; production adapter and storage are untested.

ソース位置とテストコマンドを添えます。「このルートは認可済みでは?」と聞かれても、何を確認し、どこでワークスペースが使われなくなったかを示せます。