Claude Codeで認可テストを権限マトリクスから自動生成する
ロール×エンドポイントの権限表を1枚のJSONに固定し、各セルの期待ステータスをテストコードに変換する手順。表にないルートや役割の検出つき。
認可のバグは、ログインも正常系のテストも通るので気づきにくい不具合です。editorに許したつもりのない削除が通る。あるルートだけチェックが付いていない。どちらも画面からは見えません。
防ぐ道具は、ロール(役割)を行、エンドポイントを列にした権限表です。表の各セルに「このロールでこのAPIを叩いたら200か403か」を書き、そのセルをそのままテストにします。テストの量産は機械に任せ、人が決めるのは表の中身だけにします。
この記事では、Claude Codeの読み取り専用サブエージェントで表の下書きを起こし、JSONの表からテストコードを生成して、表に載っていないルートと役割を検出するところまでを、動くサンプルで通します。
全体の流れ
権限表からテストが動くまでの 5 段階
- 1
サブエージェントがルートを列挙して下書きを作る
ルート定義を読み、メソッドとパス、コード上の許可ロール、根拠の行番号を返させます。
- 2
人が期待ステータスを決めて権限表を確定する
設計上の意図に照らして、各セルの200・401・403を書き込みます。
- 3
スクリプトが表からテストを生成する
セルごとに1テスト。生成物は手で編集しません。
- 4
テストを実行して食い違いを読む
落ちたセルが、実装か表のどちらかの誤りです。
- 5
表にないルートと役割を検出する
登録済みのルートが表に載っているかを、テストの1本として持たせます。
所有者の軸ではなく、ロールの軸を見る
認可の穴には軸が2つあります。ひとつは「このレコードは、このユーザーのものか」という所有者の軸で、IDOR(認可漏れ)と呼ばれる型です。手順はIDORをエンドポイント×所有者の表で洗い出す記事にあります。
この記事が扱うのは、もうひとつの「このロールに、この操作を許してよいか」という軸です。viewerがPOSTできる、editorがユーザー管理に入れる、といった垂直方向の権限のずれがここに入ります。
OWASPのAuthorization Cheat Sheetは、設計段階で「ユーザーの種類、公開するリソース、実行されうる操作」を列挙し、その組み合わせごとに許す操作を決めるよう勧めています。続けて、設計で決めた権限が実装で強制されているかを検証するテストを作るよう求めています。権限表は、この「組み合わせごとに決める」作業を紙の上に残したものです。
権限表をJSONで固定する
表は、人が読めて機械も読める形にします。サンプルでは matrix.json に置きました。
{
"roles": ["anonymous", "viewer", "editor", "admin"],
"endpoints": [
{
"method": "DELETE",
"path": "/articles/:id",
"expect": {
"anonymous": 401, "viewer": 403,
"editor": 403, "admin": 200
}
}
]
}expect には、ロールごとの期待ステータスを書きます。決めておく約束は3つです。
- ログインしていない場合は401、ログイン済みで権限がない場合は403にする
- 「許可」はまとめて200と書き、成功時の細かい違い(201や204)はこの表では区別しない
- 存在を隠したい操作を404で返す方針なら、そのセルに404と書く
3つ目は、アプリの方針によって変わります。403で明示するか404で隠すかは、サブエージェントには判断できません。人が決めて、表に書きます。
サブエージェントで下書きを起こす
ルート定義が数十本を超えると、表を手で起こすだけで抜けが出ます。列挙はサブエージェントに任せます。サブエージェントは .claude/agents/ にMarkdownファイルを置いて定義します。tools を省くと使えるツールをすべて引き継ぐので、読み取りだけに絞って列挙します。
---
name: authz-matrix-drafter
description: ルート定義を列挙し、コード上の許可ロールを根拠つきで表にする。コードは変更しない
tools: Read, Grep, Glob
model: inherit
---
あなたは認可設計のレビュー担当です。コードを変更しません。
1. ルート登録を Grep で全件拾い、メソッドとパスを列挙する
2. ルートごとに、コードが許可しているロールを読む
(ハンドラ内、ミドルウェア、デコレーターのどれでもよい)
3. 認可チェックが見つからないルートは「チェックなし」と書く
出力は次の列の表だけにする:
メソッド | パス | コード上の許可ロール | 根拠(ファイル:行)
根拠を示せないものは「確認できず」にする。
期待ステータス(あるべき姿)は書かない。それは人が決める。最後の1行が、この設計の要点です。サブエージェントに「あるべき姿」まで書かせると、実装を読んでそのまま写した表ができます。その表からテストを作ると、バグも含めて「正しい」とするテストになり、何も検出できません。下書きに載るのは「コードが今やっていること」だけにして、期待値は人が上書きします。
tools は許可リストで、カンマ区切りの文字列でもYAMLのリストでも書けます。model に inherit を指定すると、メインの会話と同じモデルで動きます。
呼び出しは @ メンションで固定します。
@"authz-matrix-drafter (agent)" src/routes を対象に、全ルートの許可ロールを表にして@ メンションは、どのサブエージェントを動かすかを固定します。サブエージェントへの指示文はClaudeが組み立てるため、対象ディレクトリはメッセージに書いておくと伝わります。
下書きと権限表を突き合わせる
下書きの「コード上の許可ロール」と、人が決めた expect がずれているセルは、そのままバグの候補です。たとえば下書きの DELETE /articles/:id が「admin, editor」と報告しているのに、設計の意図はadminだけだった、という具合です。
目視でも分かりますが、取りこぼしを避けるために、次の節のテストでも二重に確かめます。サブエージェントの読み違いと実装の誤りを、分けて考えられるからです。
表からテストコードを生成する
生成はClaudeに書かせず、小さなスクリプトに固定します。毎回同じ入力から同じテストが出ることが、表を信頼する前提だからです。サンプルでは gen-tests.mjs が、セルごとに1テストを出力します。
for (const e of m.endpoints) for (const r of m.roles) {
if (!(r in e.expect))
throw new Error(`権限表に無いセル: ${e.method} ${e.path} / ${r}`);
out += `
test(${JSON.stringify(`${e.method} ${e.path} as ${r} -> ${e.expect[r]}`)},
async () => {
const res = await call('${e.method}', '${e.path}', '${tok[r]}');
assert.equal(res.status, ${e.expect[r]});
});
`;
}先頭の if が、表にない役割を検出する部分です。roles に新しいロールを足したのに、あるエンドポイントの expect がそのロールを書いていなければ、生成の時点で止まります。サンプルで auditor を足すと、権限表に無いセル: GET /articles / auditor というエラーで生成が止まりました。
生成したテストは、Node標準のテストランナー(node:test)で動きます。トークンはロールごとに固定値を用意し、anonymous はヘッダーなしで呼びます。
表にないルートを検出する
セルの検査だけでは、権限表そのものに載っていないルートは見つかりません。表に1行も無いルートは、1セルも検査されないからです。
そこで、アプリが登録しているルートの一覧と、表の行を突き合わせるテストを、生成物に1本足します。
const inMatrix = new Set(["GET /articles", "DELETE /articles/:id"]);
test('登録済みの全ルートが権限表に載っている', () => {
const missing = routes
.map((r) => r.method + ' ' + r.path)
.filter((k) => !inMatrix.has(k));
assert.deepEqual(missing, []);
});サンプルのサーバーは routes 配列をエクスポートしています(inMatrix は実際には表の全行から生成されます)。ExpressやFastAPIのような実フレームワークでは、ルーターが持つ登録済みルートの一覧を取り出す方法がそれぞれ違います。取り出し方が難しいときは、前の節のサブエージェントの列挙結果を一覧として使う手があります。
実行して食い違いを読む
サンプルには、意図的に2種類の誤りを入れました。editorにDELETEを許していること、/reports に認可チェックがないこと。さらに、表に載せ忘れたルート GET /admin/export を1つ登録しています。
node gen-tests.mjs
node --test authz.test.mjsNode.js v20.18.0での実行結果は、33件中29件が成功、4件が失敗でした。
| 失敗したテスト | 期待 | 実際 | 原因 |
|---|---|---|---|
| DELETE /articles/:id as editor | 期待403 | 実際200 | 原因許可ロールにeditorが入っている |
| GET /reports as anonymous | 期待401 | 実際200 | 原因認可チェックがない |
| GET /reports as viewer | 期待403 | 実際200 | 原因同上 |
| 登録済みの全ルートが権限表に載っている | 期待空配列 | 実際GET /admin/export | 原因表の載せ忘れ |
/reports はeditorとadminだけに許す設計なので、editorとadminのセルは200で通ります。落ちたのはanonymousとviewerの2セルだけです。「認可チェックがない」ルートは、許可側のセルでは正常に見えるため、禁止側のセルがないと見つけられません。表の全セルを検査する意味は、ここにあります。
4件目の失敗は、表にないルートの検出です。/admin/export は誰も検査していませんでした。表に行を足してから、テストを通すかどうかを決めます。
直して通す
実装側は、DELETEの許可からeditorを外し、/reports に allow('admin', 'editor') を付けました。表側は /admin/export の行(adminだけ200)を足しました。
{ method: 'DELETE', path: '/articles/:id', allowed: allow('admin') },
{ method: 'GET', path: '/reports', allowed: allow('admin', 'editor') },再生成して再実行すると、テストは37件、すべて成功しました。9行×4ロールの36セルと、ルート網羅の1本です。
ここで気をつけたいのは、表を直して通した場合の扱いです。落ちたテストを通すために、表の期待値を実装に合わせて書き換えることもできてしまいます。その書き換えは、バグを正と認める操作になります。表を変える差分は、PRで必ず人が読む対象にします。
Claude Codeに任せる範囲を決める
運用で守らせたい約束は、CLAUDE.mdに短く書いておきます。
## 認可テスト
- matrix.json が権限表の唯一の正。authz.test.mjs は生成物なので編集しない
- matrix.json の expect を変えるときは、変更理由を PR に書く
- ルートを追加・削除したら、matrix.json の行も同じ PR で直す
- テストが落ちたら、まず実装の誤りを疑う。expect を実装に合わせて直さない生成と実行のコマンドは、確認なしで走らせてよい範囲として許可ルールに入れておくと、往復が減ります。許可ルールは .claude/settings.json の permissions.allow に書きます。
{
"permissions": {
"allow": [
"Bash(node gen-tests.mjs)",
"Bash(node --test *)"
]
}
}コマンドの引数が変わる部分は * に置き換えます。* はサブコマンドの後ろに置くのが決まりで、Bash(node --test *) なら node --test で始まるコマンドだけが対象です。
表だけでは見つからないもの
この手順の検査は、ステータスコードの一致だけです。次のものは見つかりません。
- 所有者の取り違え。editorが他人の記事を更新できるかは、ロール軸の表では見えません。IDORの表と組み合わせます
- 200でも返す項目が多すぎる問題。権限のない列が混ざっていても、ステータスは200のままです
- 実行時に動的に登録されるルート。Grepで拾えず、サブエージェントの列挙から漏れます
- 属性やテナントに依存する条件付きの許可。1セル1ステータスの表では書き分けられません
OWASPのAuthorization Cheat Sheetも、単体・結合テストによる認可の検証は手の届きやすい問題を拾うもので、巧妙な攻撃までは見つけられないと述べています。手で行う確認やペネトレーションテストの代わりにはなりません。
ロールの数が増えると、表は縦にも横にも広がります。同じ文書は、ロールベースの方式は最初の設定が簡単でも、ロールが増えるにつれてテストと監査が難しくなるとしています。ロールが増えてきたら、属性やリレーションで判断する方式の検討が候補になります。
本番に近い環境に広げるとき
サンプルは、プロセス内でサーバーを立てて叩くため、本番のデータや権限には触れません。実プロジェクトでテスト用の環境に向けるときは、ロールごとのテスト用アカウントとトークンを用意します。本番のアカウントを使い回さないでください。
本番の接続を伴う場合の権限設計は、MCPで本番環境に接続する権限設計で扱っています。データベース側の行レベルの権限なら、SupabaseのRLSポリシーを洗い出すレビュー用サブエージェントが参考になります。サブエージェントを読み取り専用に絞る方法そのものは、hookで読み取り専用に絞る手順にあります。
まとめ
ロール軸の認可テストは、権限表を先に固定すると、あとの工程がほぼ機械になります。サブエージェントは「コードが今やっていること」を根拠つきで列挙するところまで。期待値は人が書き、テストはスクリプトが生成する、という分担が肝です。
サンプルでは、禁止側のセルを検査して初めて、認可チェックのないルートが見つかりました。許可側のセルは全部通っていたからです。表の全セルを埋める手間は、そのためにあります。表を直す変更は、実装の変更と同じ重さでレビューする運用に乗せると、表が形骸化しません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。