Claude Media
Claude CodeでStrapiの権限を書く — 公開ロールの設定漏れをsupertestで確かめる

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. 1

    開いているルートを数える

    auth: falseの付いたルートと、Publicロールに開いたアクションを洗い出します。

  2. 2

    ポリシーを書いてルートに付ける

    未認証を弾くポリシーをfindとfindOneに付けます。

  3. 3

    コントローラーで出力を絞る

    findを差し替えるときは、入力と出力のsanitizeを通します。

  4. 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 0

PostToolUseの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のテストに見えるのは起動時の既定の許可だけなので、この一覧の代わりにはなりません。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.strapi.io/cms/features/users-permissions
  2. docs.strapi.io/cms/backend-customization/policies
  3. docs.strapi.io/cms/backend-customization/routes
  4. docs.strapi.io/cms/backend-customization/controllers
  5. docs.strapi.io/cms/testing
  6. code.claude.com/docs/en/hooks
この記事を共有:XはてブLinkedIn