Narrowing a Page's Roles Left the Action's Gate
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Narrowing a page's requireRole does not carry over to the Server Actions its child components call: each holds its own requireRole, and when the role sets diverge you get an uncaught ForbiddenError.
The short version
Narrowing a page’s requireRole does not automatically carry over to the authorisation on Server Actions its child components call. Narrowing the dashboard page’s allowed roles from PUBLISHER_ROLES (school_admin / teacher) to SYSTEM_ADMIN_ROLES (system_admin only) left the “AI effect comment” panel still on the page with its Server Action on the PUBLISHER_ROLES gate, so a system_admin — newly able to reach the page — pressing the button produced an uncaught ForbiddenError. Code review caught it before merge and it was resolved in the same commit by removing the panel.
Where it started
The admin screens of キミテラス-v2 had an “effect dashboard” (/admin/dashboard) where the school side (school_admin / teacher) watched their own operations. In one change, monitoring and viewing pages of this kind — dashboard, monthly report, sensor management — were consolidated as operator-only (system_admin) and removed from the school side, on the policy that they are “not features that make a teacher’s job easier”. The school_admin / teacher menu entries were deleted from nav.ts, and the page’s requireRole was tightened to SYSTEM_ADMIN_ROLES.
// apps/web/app/admin/dashboard/page.tsx:55
await requireRole(SYSTEM_ADMIN_ROLES);
The roles that can open the page swapped from school_admin / teacher to system_admin.
Why
This dashboard page rendered a Client Component, <EffectCommentPanel />. On a button press it calls a Server Action, generateEffectComment, which has an AI summarise the current and previous month’s responses. The call site has no 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 itself, meanwhile, holds its own authorisation by passing allowedRoles: PUBLISHER_ROLES to withSession, throwing ForbiddenError if the role does not match. It is built to propagate authentication and permission errors to the caller deliberately.
// apps/web/lib/dashboard/effect-comment-action.ts:76-77, 133-140
* @throws {UnauthenticatedError} not authenticated (from `withSession`)
* @throws {ForbiddenError} role is not in PUBLISHER_ROLES (from `withSession({allowedRoles})`)
...
{ allowedRoles: PUBLISHER_ROLES },
);
} catch (err) {
// auth/permission errors propagate to the caller (UnauthenticatedError / ForbiddenError).
// Everything else (Vertex/DB faults)
Narrowing the page’s requireRole to SYSTEM_ADMIN_ROLES let system_admin open the page. But this Server Action’s allowedRoles remained PUBLISHER_ROLES, which does not include system_admin. A system_admin pressing the button makes withSession throw ForbiddenError, and since EffectCommentPanel does not catch it, it does not land in the result area the way other errors such as pii_leak or ai_disabled do — it escapes as an uncaught exception. The cause is the structure itself: the page’s authorisation and the Server Action’s authorisation are separate function calls, and narrowing one does not change the other.
Fixing it
Code review pointed out that “system_admin can now open the dashboard while pressing the panel’s button produces an uncaught ForbiddenError”. In response, rather than widening the Server Action’s allowed roles to include system_admin, we removed <EffectCommentPanel /> from this page.
// apps/web/app/admin/dashboard/page.tsx:140 (the comment left after removal)
{/* The AI effect comment (EffectCommentPanel) is a school-only feature and was removed with the
move to system_admin-only (see docstring). Cross-school effect visibility is provided to
operators at /admin/system/dashboard. */}
The reason is not only authorisation consistency. generateEffectComment aggregates the current and previous month’s data scoped to one school (school_id), and letting system_admin — which has no school_id — call it would produce an empty aggregate with no meaning as a feature. Cross-school effect visibility already exists on a separate route, /admin/system/dashboard, so removing the panel from this school-scoped page was the coherent choice.
This fix went in in the same commit as narrowing the page’s requireRole. A page in the broken state was never merged or deployed on its own.
Preventing a repeat
A parent page’s requireRole and the allowed roles of the Server Actions it calls are, in the code, separate checks declared in separate places, and rewriting one does not carry to the other. A change narrowing a page’s authorisation only changes “the set of roles that can open this page”, not “the set of operations that can be performed inside it”. When a page contains child components or Server Actions with their own requireRole or withSession({ allowedRoles }), every change to the page’s role set has to be reconciled against theirs.
In this case, checking whether the role newly able to open the page (system_admin) was included in the Server Action’s allowed roles (PUBLISHER_ROLES) would have caught it before review. On top of that, because the Server Action was designed to throw authorisation errors rather than return a typed error result (ok: false), a calling Client Component without try/catch cannot display it naturally in-app the way other failures (pii_leak, ai_disabled) are, and it surfaces as an uncaught exception on a page with no error boundary. Agreeing between page and Server Action on which form insufficient permission takes also helps make this kind of oversight visible.
よくある質問
Q1Why a 403 after narrowing the page's authorisation?
A page's requireRole only controls reaching the page. A Server Action a child component calls holds its own check (withSession({ allowedRoles: PUBLISHER_ROLES })). Narrow the page to system_admin and the action does not follow, so pressing the button throws ForbiddenError.
Q2Did this reach production?
It did not. Narrowing the dashboard's requireRole and removing the panel are in the same commit: code review flagged the uncaught ForbiddenError, and the panel was removed before merge. A page.tsx in the broken state was never merged or deployed alone.
Q3What was the thinking behind the fix?
Rather than widening the Server Action's roles to include system_admin, we deleted <EffectCommentPanel /> from the dashboard. generateEffectComment aggregates the current and previous month for one school (school_id), and system_admin has no school_id, so the aggregate would be empty.
Q4Where should you look to avoid this pattern?
When changing a page's requireRole, enumerate the child components it renders (especially Client Components calling Server Actions) that hold their own checks, and reconcile the page's new role set with each action's allowed roles. They are separate calls; one does not follow the other.
確認した環境
- キミテラス-v2: Next.js ^16.0.0 (App Router / Server Actions). Page authorisation via requireRole in lib/auth/guard.ts; Server Action authorisation via withSession({ allowedRoles })
- Found in code review on 2026-06-05 and fixed in the same commit (never reached production)
この記事の根拠
- TypeScriptファイル 28〜55行目コミット 548a212
- TypeScriptファイル 76〜151行目コミット 548a212
- TypeScriptファイル 29〜38行目コミット 548a212
- TypeScriptファイル 58〜63行目コミット 548a212
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。