Claude CodeでStrapiの権限を書く — 公開ロールの設定漏れをsupertestで確かめる
StrapiのUsers & Permissionsは管理画面の設定なので、コードレビューに出ません。ポリシーとコントローラーで権限をコードに固定し、未認証リクエストが通らないことをsupertestとhooksで毎回確かめる流れです。
権限の設定漏れがコードに現れない理由
Strapi 5のエンドユーザー権限は、Users & Permissionsの管理画面でロールごとにチェックボックスを付けて決めます。この設定はデータベースに入るため、git diffにもプルリクエストにも出てきません。
しかも、トークンなしのリクエストはPublicロールの権限で処理されます。PublicロールのfindやfindOneにチェックが残っていれば、誰でも一覧を取れます。
Claude CodeにAPIを足させるたびに、この漏れを人の目で探すのは現実的ではありません。そこで、権限をコードに寄せる層と、それを毎回確かめるテストを用意します。
この記事の組み立て
- 管理画面の権限はDB上の設定で、コードの差分に出ない
- ルートのポリシーとコントローラーのsanitizeで、管理画面とは別の鍵をコード側に持つ
- supertestで「Publicロールを開けても、ポリシーが未認証を止める」を固定し、PostToolUse hookで編集のたびに走らせる
コンテンツタイプの定義そのものはClaude CodeでStrapiのコンテンツタイプを定義する手順で扱っています。ここでは、定義済みのAPIに権限をかぶせる部分を書きます。
作業は4段で進める
Claude Codeに権限を書かせる順序
- 1
開いているルートを数える
auth: falseの付いたルートと、Publicロールに開いたアクションを洗い出します。 - 2
ポリシーを書いてルートに付ける
未認証を弾くポリシーを
findとfindOneに付けます。 - 3
コントローラーで出力を絞る
findを差し替えるときは、入力と出力のsanitizeを通します。 - 4
supertestで固定する
未認証は通らず、ログイン済みなら通ることをテストにします。
開いているルートを先に洗い出す
まず現状の把握です。Strapiには、登録済みのルートを一覧するstrapi routes:listと、使えるポリシーを一覧するstrapi policies:listがあります。
yarn strapi routes:list
yarn strapi policies:listルートの設定にauth: falseが書かれていれば、そのルートは認証なしで呼べます。コード側の入口は、次の検索で拾えます。
grep -rn "auth: false" src/api src/pluginsこの2つはClaude Codeに実行させて、結果を表にまとめさせるところまで任せられます。
もう一方の入口はPublicロールの許可です。こちらはコードに無いので、DBから読みます。Strapiのテスト用ハーネスは、ロールをstrapi.db.query('plugin::users-permissions.role')で引いています。同じ書き方でPublicロールを引く方法は、テストの節で示します。本番や検証環境に実際に付いている許可の一覧は、ステージングのDBに向けた別スクリプトで読みます。
ポリシーで未認証をコード側から止める
ポリシーは、リクエストがコントローラーに届く前に走る関数です。ルートごとに配列で付けられ、falseを返せばリクエストはそこで止まります。
Strapiの例では、リクエストにユーザーがいるかどうかだけを見るglobalポリシーを書いています。
// ./src/policies/is-authenticated.js
module.exports = (policyContext, config, { strapi }) => {
if (policyContext.state.user) {
return true;
}
return false;
};押さえたいのは、ポリシーが何も返さなかったときの扱いです。戻り値を書かない分岐は、ブロックする意思がなかったものとして通過します。return falseは省略しません。
Users & Permissionsプラグインにも、同種のisAuthenticatedポリシーが付属します。自作せずに付ける手もありますが、完全なIDはstrapi policies:listの出力で確かめてから書きます。
ロール名で絞るポリシーの落とし穴
ロールで絞るポリシーの例は、policyContext.state.user.role.codeをconfig.roleと比べる形です。ユーザーがいないリクエストではstate.userが空なので、この式をそのまま使うと例外になりえます。未認証の入口を塞ぐ目的なら、先にユーザーの有無を見ます。
// ./src/policies/has-role.js
module.exports = (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) return false;
return user.role?.type === config.role;
};user.roleがリクエスト時点で読み込まれているかは、Users & Permissionsのページに書かれていません。typeで比べる実装は、手元のStrapiで一度動かして確かめてください。
標準ルートにポリシーを付ける
findやfindOneのような標準アクションは、createCoreRouterのconfigで個別に設定できます。標準のアクション名のままコントローラーを書き換える場合は、ルートを増やさずに済みます。
// ./src/api/restaurant/routes/restaurant.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::restaurant.restaurant', {
only: ['find', 'findOne'],
config: {
find: { policies: ['global::is-authenticated'] },
findOne: { policies: ['global::is-authenticated'] },
},
});onlyに挙げなかったアクションは、ルートごと読み込まれません。書き込み系を公開しない構成なら、create・update・deleteを最初から外しておくのが確実です。
カスタムルートを足すときは、ハンドラーをapi::restaurant.restaurant.reviewのように完全修飾で書きます。ルートファイルはアルファベット順に読まれるため、標準ルートより先に評価させたいなら01-custom-routes.jsのように名前で順序を作ります。
コントローラーでは出力を素通しにしない
コアのアクションを丸ごと差し替えるなら、入力と出力のsanitizeをコントローラー側で通します。管理画面の権限で見せない項目まで返してしまう事故を、ここで防ぎます。
// ./src/api/restaurant/controllers/restaurant.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::restaurant.restaurant', ({ strapi }) => ({
async find(ctx) {
await this.validateQuery(ctx);
const query = await this.sanitizeQuery(ctx);
const { results, pagination } = await strapi
.service('api::restaurant.restaurant')
.find(query);
const safe = await this.sanitizeOutput(results, ctx);
return this.transformResponse(safe, { pagination });
},
}));validateQueryは省いてもよい工程で、sanitizeQueryは併用が前提の書き方です。sanitizeOutputを飛ばしたコントローラーは、アクセス権のないデータを返す入口になります。Claude Codeに差し替えを頼むときは、この3行が残っているかをレビューの観点に入れます。
supertestで未認証と認証済みを固定する
テストは、Strapiのテスト用ガイドにある構成に乗せます。Jestとsupertestを入れ、tests/strapi.jsのハーネスでStrapiをin-memoryのSQLiteで起動します。
yarn add jest supertest --devハーネスの全文はガイド側にあります。ここでは、それを使うテストだけを書きます。
ハーネスは毎回まっさらなDBを作るので、PublicロールにはfindもfindOneも許可がありません。この状態で未認証のGETを送ると、ポリシーを外しても権限判定だけで止まり、テストは通ってしまいます。確かめたいのは「管理画面で誤ってPublicを開けても、ポリシーが止める」ことなので、beforeAllで許可を付けてから叩きます。
// ./tests/permissions.test.js
const { setupStrapi, cleanupStrapi } = require('./strapi');
const request = require('supertest');
const OPEN_ACTIONS = [
'api::restaurant.restaurant.find',
'api::restaurant.restaurant.findOne',
];
beforeAll(async () => {
await setupStrapi();
// 管理画面で Public にチェックを入れた状態を再現する
const role = await strapi.db
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' } });
for (const action of OPEN_ACTIONS) {
await strapi.db
.query('plugin::users-permissions.permission')
.create({ data: { action, role: role.id } });
}
});
afterAll(async () => { await cleanupStrapi(); });
const CLOSED = [
['get', '/api/restaurants'],
['get', '/api/restaurants/some-document-id'],
];
describe('Public ロールを開けても未認証は通らない', () => {
it.each(CLOSED)('%s %s は通らない', async (method, path) => {
const res = await request(strapi.server.httpServer)[method](path);
expect([401, 403]).toContain(res.status);
});
});permissionの内部名とaction・roleの項目名は、引用したページに載っていません。手元のStrapiで一度許可を付けて確かめます。付け方が合っているかは、global::is-authenticatedを外したときにこのテストが落ちること(200が返ること)で判定できます。
ステータスを[401, 403]の幅で受けているのは意図的です。Users & Permissionsのページに書かれているのは「認証の失敗は401を返す」という点までで、ポリシーがfalseを返したときの数値は載っていません。一度走らせて実際の値を確かめ、toBeに絞り込むと検査が締まります。
テストが見ているのはコード側の鍵だけ
ここに限界があります。ハーネスはDATABASE_FILENAMEを:memory:にして毎回DBを作り直します。本番の管理画面で付けた権限は、このDBに入りません。
だから、このテストが確かめるのは「Publicを開けた状態でも、コード側のポリシーが未認証を止める」ことまでです。本番や検証環境のPublicロールにどのアクションが開いているかは、このテストでは分かりません。そちらは、別スクリプトでステージングのDBに向けて、Publicロールの許可済みアクションを読み出して調べます。
// ./scripts/check-public-actions.js
// 単体の node スクリプトではなく、strapi console に貼って実行する断片
const PUBLIC_OK = []; // 未認証で開いていてよい action
(async () => {
const role = await strapi.db
.query('plugin::users-permissions.role')
.findOne({ where: { type: 'public' }, populate: ['permissions'] });
const actions = role.permissions.map((p) => p.action).sort();
console.log(JSON.stringify(actions) === JSON.stringify(PUBLIC_OK) ? 'OK' : actions);
})();ステージングのDB設定でstrapi consoleを起動し、上の断片を貼って実行します。strapiはコンソール内で使えるグローバルなので、起動のコードは要りません。
populate: ['permissions']とp.actionの名前は、Users & Permissionsプラグインの内部実装に依存します。引用したページには載っていないので、手元のStrapiで一度中身を出力して確かめます。
認証済みユーザーが通ることも、同じファイルで押さえます。ガイドのuser.addと/api/auth/localの流れでJWTを取り、Authorization: Bearerを付けて叩くだけです。鍵を閉めすぎてフロントが壊れる事故は、この対のテストで防げます。
編集のたびにテストを走らせるhook
テストを書いても、走らせ忘れれば無いのと同じです。Claude CodeのPostToolUse hookを使うと、権限まわりのファイルを編集した直後にだけ走らせられます。
.claude/settings.jsonに、Edit|Writeで発火するhookを置きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/strapi-permission-test.sh"
}
]
}
]
}
}スクリプトは標準入力のJSONから編集されたファイルのパスを取り、ルート・コントローラー・ポリシーのときだけjestを回します。
#!/bin/bash
# .claude/hooks/strapi-permission-test.sh
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
cwd=$(jq -r '.cwd' <<<"$input")
case "$file" in
*/src/api/*/routes/*|*/src/api/*/controllers/*|*/src/api/*/policies/*|*/src/policies/*) ;;
*) exit 0 ;;
esac
cd "$cwd" || exit 0
if ! out=$(npx jest tests/permissions.test.js 2>&1); then
echo "$out" | tail -40 >&2
exit 2
fi
exit 0PostToolUseのexit 2は、すでに実行された編集を取り消しません。代わりにstderrをClaudeに見せます。テストの失敗はそのままClaudeの次の入力になります。ポリシーを外すと、Publicに許可を付けた状態のテストが200を受け取って落ち、Claudeがそれを見て直します。jqがPATHに入っていることは前提です。
Bash経由でファイルが書き換えられた場合、Edit|Writeのhookは発火しません。生成コマンドでルートを作らせる運用なら、FileChangedなど別のイベントも検討します。
CLAUDE.mdに置く規約
hookは事後の検知です。事前の方針はCLAUDE.mdに短く書いておくと、最初から的外れな差分が減ります。
## Strapi の権限
- 新しいコンテンツタイプには、find / findOne / create / update / delete すべてに
`global::is-authenticated` を付ける。公開する場合は scripts/check-public-actions.js の
PUBLIC_OK に追記し、理由を PR に書く
- コアのアクションを差し替えるときは validateQuery / sanitizeQuery / sanitizeOutput を通す
- ポリシーは必ず true / false を返す。戻り値なしの分岐を作らないつまずきやすい点
落とし穴
Public ロールは削除できず、削除したロールのユーザーは Public に移る
ロール削除の画面で消せるのはPublic以外です。削除したロールに属していたユーザーは自動でPublicロールへ再割り当てされるため、ロール整理の直後に権限が広がることがあります。削除前にPublicの許可を確かめます。
- WindowsでSQLiteを使う構成は、ファイルのロックの都合でこのテスト構成が動かないと、ガイドに注意があります。CIはLinuxで回すのが無難です
yarn devの直後にyarn testを走らせると、コンパイル済みのdistにある別のDB設定を拾うことがあります。ハーネスにdistDirを渡さず、config/env/test/database.jsを使いますstrapi routes:listが示すのは、コード上のルートだけです。Publicロールに管理画面で付いた許可は別に確かめます
Claude Code側の許可設定も、同じ発想で組めます。/permissionsコマンドの記事では権限ルールの管理を、OpenTofuのvalidateとplanだけ許可する設計では実行コマンドを絞る実例を扱っています。
まとめ
Strapiの権限は、管理画面の設定とコードの二重で持つと事故が減ります。管理画面は人が見直す層、ポリシーとコントローラーのsanitizeはコードレビューに載る層です。テストは後者が崩れていないことの検査で、前者の確認にはステージングのDBを別に調べます。
hookを入れる前に、check-public-actions.jsの断片をステージングのDBに向けたstrapi consoleで一度走らせてください。PUBLIC_OKを空にしておけば、出力がそのまま、いま未認証で開いているアクションの一覧になります。in-memoryのテストに見えるのは起動時の既定の許可だけなので、この一覧の代わりにはなりません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。