Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

親ページの許可ロールだけ絞り込み、子コンポーネント内の別ロールガードを直し忘れて未捕捉の403例外を出した

公開 読了時間 約6分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

ページの requireRole を絞り込んでも、そのページが呼ぶ子コンポーネントのServer Actionは別に自分の requireRole を持つため自動では追随せず、両者のロール集合がずれると未捕捉の ForbiddenError になる。

結論

ページの requireRole を絞り込んでも、そのページが描画する子コンポーネントが独自に呼ぶ Server Action の認可は自動では追随しない。 ダッシュボードページの許可ロールを PUBLISHER_ROLES(school_admin / teacher)から SYSTEM_ADMIN_ROLES(system_admin のみ)に絞り込んだところ、同じページ内に置いたままだった「AI 効果コメント」パネルの Server Action は PUBLISHER_ROLES gate のままで、ページに新しく到達できるようになった system_admin がボタンを押すと未捕捉の ForbiddenError になる状態が生まれた。コードレビューでマージ前に見つかり、同じコミット内でパネルごと撤去して解消した。

発端

キミテラス-v2 の管理画面には、学校側(school_admin / teacher)が自校の運営を見るための「効果ダッシュボード」(/admin/dashboard)があった。ある変更で、この種の監視・閲覧系ページ(ダッシュボード・月次レポート・センサー管理)は「先生の校務を楽にする機能ではない」という方針のもと、学校側から撤去して運営(system_admin)専用に一本化することになった。nav.ts からは school_admin / teacher 向けのメニュー項目が削除され、ページ側の requireRoleSYSTEM_ADMIN_ROLES に締められた。

// apps/web/app/admin/dashboard/page.tsx:55
await requireRole(SYSTEM_ADMIN_ROLES);

ページを開けるロールが school_admin / teacher から system_admin へ入れ替わった形になる。

原因

このダッシュボードページは、<EffectCommentPanel /> という Client Component を描画していた。ボタン押下で generateEffectComment という Server Action を呼び、当月・前月の反応を AI に要約させる機能で、呼び出し側に try/catch は無い。

// apps/web/app/admin/dashboard/_components/EffectCommentPanel.tsx:32-38
function onGenerate() {
  setResult(null);
  startTransition(async () => {
    const res = await generateEffectComment();
    setResult(res);
  });
}

一方、generateEffectComment 自身は withSessionallowedRoles: PUBLISHER_ROLES を渡して独自に認可を持ち、role が合わなければ ForbiddenError を投げる。認証・権限エラーは意図的に呼び出し側へそのまま伝播させる作りになっている。

// apps/web/lib/dashboard/effect-comment-action.ts:76-77, 133-140
 * @throws {UnauthenticatedError} 未認証 (`withSession` 由来)
 * @throws {ForbiddenError} role が PUBLISHER_ROLES でない (`withSession({allowedRoles})` 由来)
...
      { allowedRoles: PUBLISHER_ROLES },
    );
  } catch (err) {
    // 認証/権限エラーは呼び出し側へ伝播 (UnauthenticatedError / ForbiddenError)。それ以外 (Vertex/DB 障害)

ページ側の requireRoleSYSTEM_ADMIN_ROLES に絞ったことで、system_admin はページを開けるようになった。しかしこの Server Action の allowedRolesPUBLISHER_ROLES のまま変更されておらず、system_admin はそこに含まれていない。system_admin がボタンを押すと withSessionForbiddenError を投げ、それを EffectCommentPanel 側は catch していないため、pii_leakai_disabled のような他のエラーのように結果表示欄へ収まらず、未捕捉の例外として上がる。ページの認可と Server Action の認可は別の関数呼び出しであり、片方を絞ってももう片方は自動では変わらない、という構造そのものが原因だった。

直し方

コードレビューで、「system_admin がダッシュボードを開けるようになった一方で、パネルのボタンを押すと未捕捉の ForbiddenError になる」と指摘された。この指摘を受けて、Server Action 側の許可ロールを system_admin にも広げるのではなく、<EffectCommentPanel /> をこのページから撤去する方を選んでいる。

// apps/web/app/admin/dashboard/page.tsx:140(撤去後のコメント)
{/* AI 効果コメント (EffectCommentPanel) は school 専用機能のため system_admin 専用化に伴い撤去
    (docstring 参照)。全校横断の効果可視化は /admin/system/dashboard で運営に提供する。 */}

理由は認可の整合だけではない。generateEffectComment は自校(school_id)スコープの当月・前月データを集計する処理で、school_id を持たない system_admin がこの Server Action を呼べるようにしたところで、集計自体が空になり機能として意味が無い。全校横断の効果可視化はすでに別ルート /admin/system/dashboard に用意されているため、この自校専用ページからはパネルごと取り除くのが筋が通っていた。

この修正はページの requireRole を絞り込んだのと同じコミットの中で行われている。問題を含んだ状態のページが単独でマージ・デプロイされたことは無い。

再発防止

親ページの requireRole と、そのページが呼ぶ Server Action 自身の許可ロールは、コード上は別々の場所で宣言された別々のチェックであり、片方を書き換えても他方は自動では追随しない。ページの認可を絞り込む変更は「このページを開けるロールの集合」を変えるだけで、「このページの中で実行できる操作の集合」までは変えていない。ページ内に、独自の requireRolewithSession({ allowedRoles }) を持つ子コンポーネント・Server Action がある場合、ページ側のロール集合を変えるたびに、それらの許可ロール集合と突き合わせる必要がある。

このケースでは、変更後にページを開けるようになったロール(system_admin)が、Server Action 側の許可ロール(PUBLISHER_ROLES)に含まれているかどうかを確認していれば、レビュー前に自分で気づけた。加えて、Server Action 側は認可エラーを typed error result(ok: false)ではなく throw する設計だったため、呼び出し側の Client Component に try/catch が無いと、他の失敗(pii_leakai_disabled)のようにアプリ内で自然に表示されず、error boundary の無いページでは未捕捉の例外として露出する。ページと Server Action の間で「権限不足はどちらの形で扱うか」を揃えておくことも、この種の見落としを目立たせる助けになる。

よくある質問

Q1なぜページの認可を絞ったのに403になるのですか?

ページの `requireRole` はページ自体への到達を制御するだけで、ページが描画する子コンポーネントが呼ぶ Server Action は自分自身の認可チェック(`withSession({ allowedRoles: PUBLISHER_ROLES })`)を別に持っている。ページ側を system_admin だけに絞っても Server Action 側は自動では追随せず、ページに到達できるようになった system_admin がその許可ロールに含まれていないため、ボタンを押すと `ForbiddenError` になる。

Q2本番でも起きた問題ですか?

起きていません。ダッシュボードの `requireRole` を絞り込む変更とパネルの撤去は同じコミットに含まれており、コードレビューで未捕捉の `ForbiddenError` になることを指摘されたのを受けて、マージ前にパネルごと撤去された。問題を含んだ状態の `page.tsx` が単独でマージ・デプロイされたことは無い。

Q3直し方はどういう発想ですか?

Server Action 側の許可ロールを system_admin にも広げる方向ではなく、`<EffectCommentPanel />` をダッシュボードから削除する方向を選んだ。`generateEffectComment` は自校(school_id)の当月・前月データを集計する処理で、school_id を持たない system_admin がそのロールに含まれても集計自体が空になり機能として意味を持たない。全校横断の効果可視化は別ルート `/admin/system/dashboard` に既にあるため、このページからはパネルごと撤去するのが妥当だった。

Q4同じパターンを避けるにはどこを見ればいいですか?

ページの `requireRole` を変更するときは、そのページが描画する子コンポーネント(特に Server Action を呼ぶ Client Component)が独自の認可チェックを持っていないかを洗い出し、ページ側の新しいロール集合と Server Action 側の許可ロール集合を突き合わせる。両者は別々の関数呼び出しなので、片方を直しても他方は自動では追随しない。

確認した環境

  • キミテラス-v2: Next.js ^16.0.0(App Router / Server Actions)。ページの認可は lib/auth/guard.ts の requireRole、Server Action の認可は withSession({ allowedRoles })
  • 2026-06-05 にコードレビューで発見、同じコミット内で修正(本番には未到達)

この記事の根拠

  • TypeScriptファイル 28〜55行目コミット 548a212
  • TypeScriptファイル 76〜151行目コミット 548a212
  • TypeScriptファイル 29〜38行目コミット 548a212
  • TypeScriptファイル 58〜63行目コミット 548a212

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。