DELOGs
[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する

管理画面フォーマット開発編 #6
RBAC調整 ─ ページ単位のアクセス制御を実装する

これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張

初回公開日

最終更新日

0. はじめに

これまでの記事シリーズでは、管理画面における RBAC(Role-Based Access Control) を「メニュー表示」に限定して実装してきました。
具体的には、ユーザの rolePriority と各メニューの minPriority を比較し、十分な権限を持つユーザだけが該当メニューを表示できる仕組みです。
しかし実際の運用では、URLを直接指定したアクセス や、メニューに載せていない隠しページ に対してもアクセス制御を行う必要があります。
今回の記事では、この RBAC の仕組みを ページ単位のアクセス制御 に拡張し、SSRで動作する page.tsx にも組み込めるようにします。

技術スタック

Tool / LibVersionPurpose
React19.xUIの土台。コンポーネント/フックで状態と表示を組み立てる
Next.js15.xフルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理
TypeScript5.x型安全・補完・リファクタリング
shadcn/uilatestRadixベースのUIキット
Tailwind CSS4.xユーティリティファーストCSSで素早くスタイリング
Zod4.xスキーマ定義と実行時バリデーション
本記事では、前回の記事 【管理画面フォーマット開発編 #5】ユーザプロフィール更新 までのソースコードを引き継いで追加・編集していきます。

1. RBAC仕様の整理

RBAC(Role-Based Access Control)の仕組みを、管理画面全体でどのように適用するかを整理します。
ここでは「メニューとminPriority」「ユーザのrolePriority」「ページ内部機能のフラグ」の3つに分けて解説します。

メニューとminPriority

メニューごとに設定される minPriority は、そのメニューや対応するページを表示するために必要な最小権限を示します。
ページ単位でのガード処理も、この minPriority を基準にして判定します。
txt
1┌────────────┬──────────────────────────┐ 2│ 項目 │ 意味 │ 3├────────────┼──────────────────────────┤ 4│ minPriority │ ページやメニューに必要な権限 │ 5│ rolePriority│ ユーザが保持している権限値 │ 6└────────────┴──────────────────────────┘
たとえば、あるメニューの minPriority = 50 の場合、ユーザの rolePriority が 50以上であればアクセス可能です。
未満であればメニュー非表示やページアクセス拒否となります。

ユーザのrolePriority

各ユーザには Role が割り当てられており、その priority 値が rolePriority として扱われます。
これをログイン時にDBから取得し、AuthContextuserSnapshot を通じてアプリ内で参照可能にします。
txt
1┌────────────┬───────────────┐ 2│ Role名 │ priority値 │ 3├────────────┼───────────────┤ 4│ ADMIN │ 100 │ 5│ EDITOR │ 50 │ 6│ VIEWER │ 10 │ 7└────────────┴───────────────┘
このように数値化することで、メニューやページに対するアクセス制御を一元的に判定できます。

canEditData / canDownloadDataとの関係

Role には canEditDatacanDownloadData といった機能フラグも定義されています。
これらは ページの表示可否には関与せず 、ページ内部の操作(編集ボタンの有効化やCSV出力の許可など)を制御するために用います。
txt
1┌───────────────┬─────────────────────────────┐ 2│ 判定対象 │ 制御内容 │ 3├───────────────┼─────────────────────────────┤ 4│ rolePriority │ ページ表示の可否(ガード) │ 5│ canEditData │ 編集フォームや保存処理の有効化 │ 6│ canDownloadData│ CSVダウンロード等の機能利用可否 │ 7└───────────────┴─────────────────────────────┘
この整理により、RBACは次のように二層で機能します。
  1. ページ単位の表示可否 → rolePriority と minPriority の比較
  2. ページ内部の機能制御 → canEditData / canDownloadData のフラグ参照
これを明確に分けて実装することで、アプリ全体の構造が分かりやすくなり、将来の拡張にも対応しやすくなります。

2. Menuモデルへのhidden追加

今回の記事では、まだDBの Menu テーブルは利用していません。
しかし、将来的に INITIAL_MENU_RECORDSMenu テーブルへ移行することを見据えて、 あらかじめ hidden カラムを追加 しておきます。
このカラムを用いることで、 ナビゲーションに表示しないがRBAC判定には必要なページ を扱えるようになります。 なお、本記事では、 メニューの表示処理部分の修正は行わず、 別途メニューのDB連携の記事で行いますhiddenが指定されたメニューも本記事の段階ではサイドメニューに表示されたままになります)。

prisma/schema.prismaの修正

model Menuhidden カラムを追加します。
既定値は false とし、通常のメニューは表示対象、hidden: true に設定されたメニューはナビゲーションに出さないようにします。
prisma
1// prisma/schema.prisma(差分抜粋) 2model Menu { 3 id String @id @default(uuid()) 4 displayId String @unique @default(dbgenerated("generate_display_id('menu_display_id_seq','MN')")) @db.VarChar(10) 5 isActive Boolean @default(true) 6 createdAt DateTime @default(now()) @db.Timestamptz 7 updatedAt DateTime @updatedAt @db.Timestamptz 8 deletedAt DateTime? @db.Timestamptz 9 10 parentId String? 11 title String 12 href String? 13 isExternal Boolean? 14 iconName String? 15 match MenuMatchMode 16 pattern String? 17 minPriority Int? 18 isSection Boolean 19 sortOrder Int 20 remarks String? 21 22 // ★ 追加 23 hidden Boolean @default(false) 24 25 parent Menu? @relation("MenuToMenu", fields: [parentId], references: [id], onDelete: Restrict) 26 children Menu[] @relation("MenuToMenu") 27 28 @@index([parentId]) 29 @@index([minPriority]) 30 @@index([isActive]) 31 @@index([sortOrder]) 32 @@index([createdAt]) 33 @@index([hidden]) // 追加 34}
修正したら、マイグレーションを実行します。
zsh
1npx prisma migrate dev --name add-hidden-to-menu 2npx prisma generate
これにより、Menu テーブルを導入した際にも、隠しページ を扱える準備が整います。

menu.schema.ts の修正

hiddenデータ型レベルで保証 するため、Zod スキーマにプロパティを追加します。
これにより、INITIAL_MENU_RECORDS への hidden: true 追加時に型・バリデーションが一貫します。
ts
1// src/lib/sidebar/menu.schema.ts(差分:hidden を追加) 2import { z } from "zod"; 3 4// …前略… 5 6/** 編集用の1レコード(UI専用) */ 7export const menuRecordSchema = z 8 .object({ 9 displayId: z.string().min(1), 10 parentId: z.string().nullable(), 11 order: z.number().int().nonnegative(), 12 title: z.string().min(1), 13 href: z 14 .string() 15 .regex(/^\/(?!.*\/$).*/, "先頭は /、末尾スラッシュは不可") 16 .optional(), 17 iconName: z.string().optional(), 18 match: z.enum(["exact", "prefix", "regex"]).default("prefix"), 19 pattern: z.string().optional(), 20 minPriority: z.number().int().positive().optional(), 21 isSection: z.boolean().default(false), 22 isActive: z.boolean(), 23 // ★ 追加: ナビには出さないが RBAC 判定には使いたい時に true 24 hidden: z.boolean().default(false), 25 }) 26 .superRefine((val, ctx) => { 27 // 見出しノードのときはリンク関連を禁止(従来ロジックそのまま) 28 if (val.isSection) { 29 if (val.href) { 30 ctx.addIssue({ code: "custom", message: "セクションではhref不要です" }); 31 } 32 if (val.pattern) { 33 ctx.addIssue({ code: "custom", message: "セクションではpattern不要です" }); 34 } 35 } 36 37 // regex 指定時は pattern 必須 / それ以外では不要(従来ロジックそのまま) 38 if (val.match === "regex" && !val.pattern) { 39 ctx.addIssue({ code: "custom", message: "regex指定時はpattern必須です" }); 40 } 41 if (val.match !== "regex" && val.pattern) { 42 ctx.addIssue({ code: "custom", message: "regex以外でpattern不要です" }); 43 } 44 }); 45 46export type MenuRecord = z.infer<typeof menuRecordSchema>;
  • hiddenデフォルト false。明示したときだけ「ナビ非表示」にできます。
  • isActive(無効化=全体から除外)とは責務を分離しています(両方 false/true の組み合わせを許容)。
  • 既存の superRefine(見出しのリンク禁止、regex/pattern の整合)はそのまま活かします。

Create/Update スキーマへの組み込み

新規作成・更新フローでも hidden を扱えるよう、menuCreateSchema に追加します。
menuUpdateSchemamenuCreateSchema.extend({...}) なので、自動的に取り込み可能です。
tsx
1// src/lib/sidebar/menu.schema.ts(差分:Create/Update に hidden を追加) 2 3export const menuCreateSchema = z 4 .object({ 5 parentId: z.string().nullable(), 6 title: z.string().min(1, "タイトルは必須です"), 7 isSection: z.boolean().default(false), 8 href: z 9 .string() 10 .regex(/^\/(?!.*\/$).*/, "先頭は /、末尾スラッシュは不可") 11 .optional(), 12 match: z.enum(["exact", "prefix", "regex"]).default("prefix"), 13 // 「詳細設定」想定:regex のときだけ pattern を入れられる 14 pattern: z.string().optional(), 15 iconName: z.string().optional(), 16 // 未選択=全員表示。入力では string/number/空文字を受けて number | undefined に正規化 17 minPriority: z 18 .union([z.string(), z.number()]) 19 .optional() 20 .transform((v) => (v === "" || v === undefined ? undefined : Number(v))), 21 isActive: z.boolean().default(true), 22 // ★ 追加: 作成フォームからも hidden を設定可能に 23 hidden: z.boolean().default(false), 24 }) 25 .superRefine((val, ctx) => { 26 // 見出しの時はリンク禁止(従来ロジック) 27 if (val.isSection) { 28 if (val.href) { 29 ctx.addIssue({ code: "custom", message: "見出しでは href は不要です", path: ["href"] }); 30 } 31 if (val.pattern) { 32 ctx.addIssue({ code: "custom", message: "見出しでは pattern は不要です", path: ["pattern"] }); 33 } 34 } 35 // 見出しOFF時は parentId 必須(従来ロジック) 36 if (!val.isSection && !val.parentId) { 37 ctx.addIssue({ code: "custom", message: "親メニューを選択してください", path: ["parentId"] }); 38 } 39 // regex/pattern 整合(従来ロジック) 40 if (val.match === "regex" && !val.pattern) { 41 ctx.addIssue({ code: "custom", message: "regex 指定時は pattern が必要です", path: ["pattern"] }); 42 } 43 if (val.match !== "regex" && val.pattern) { 44 ctx.addIssue({ code: "custom", message: "regex 以外では pattern は不要です", path: ["pattern"] }); 45 } 46 }); 47 48export type MenuCreateInput = z.input<typeof menuCreateSchema>; 49export type MenuCreateValues = z.output<typeof menuCreateSchema>; 50 51// Update は Create を拡張しているため hidden も自動的に対象になる 52export const menuUpdateSchema = menuCreateSchema 53 .extend({ 54 displayId: z.string().min(1), 55 order: z.number().int().nonnegative(), 56 }) 57 .superRefine((val, ctx) => { 58 // Create と同じ相関チェックを維持(従来ロジック) 59 if (val.isSection) { 60 if (val.href) { 61 ctx.addIssue({ code: "custom", message: "見出しでは href は不要です", path: ["href"] }); 62 } 63 if (val.pattern) { 64 ctx.addIssue({ code: "custom", message: "見出しでは pattern は不要です", path: ["pattern"] }); 65 } 66 } 67 if (val.match === "regex" && !val.pattern) { 68 ctx.addIssue({ code: "custom", message: "regex 指定時は pattern が必要です", path: ["pattern"] }); 69 } 70 if (val.match !== "regex" && val.pattern) { 71 ctx.addIssue({ code: "custom", message: "regex 以外では pattern は不要です", path: ["pattern"] }); 72 } 73 }); 74 75export type MenuUpdateInput = z.input<typeof menuUpdateSchema>; 76export type MenuUpdateValues = z.output<typeof menuUpdateSchema>;
  • 作成フォームでも hidden を自然に扱えるようになります(既定は false)。
  • 更新フォームは Create を継承する設計のため、追加作業なしで hidden 対応になります。

表示と判定の責務分離(確認)

hidden の意味合いを UI と RBAC で混同しないよう、責務を表で再確認しておきます。
txt
1┌──────────────┬─────────────────────────────┬──────────────────────────┐ 2│ プロパティ │ サイドバー表示への影響 │ RBACガード判定への影響 │ 3├──────────────┼─────────────────────────────┼──────────────────────────┤ 4│ isActive=false│ 完全に出さない(無効・論理削除) │ そもそも候補に含めない │ 5│ hidden=true │ 出さない(非ナビ項目) │ **候補に含める(判定に使う)** │ 6│ hidden=false │ 出す(通常項目) │ 候補に含める │ 7└──────────────┴─────────────────────────────┴──────────────────────────┘
この分離により、**「見せないけど守る」**を安全に達成できます。
サイドバー描画では hidden を除外、ページガードでは hidden を含む生データを参照します(前章の方針どおり)。

menu.mock.ts のINITIAL_MENU_RECORDS配列の修正

型の二重定義を避けるため、menu.mock.ts 側は Zod 由来の MenuRecord を すでにimport して使っています。 少し面倒ですが、INITIAL_MENU_RECORDS配列の各要素に、前節でスキーマに追加したhiddenカラムを追加します。
ts
1// src/lib/sidebar/menu.mock.ts(サンプル) 2 { 3 displayId: "M00000011", 4 parentId: "M00000003", 5 order: 1, 6 title: "ユーザ管理", 7 href: "/users", 8 iconName: undefined, 9 match: "prefix", 10 pattern: undefined, 11 minPriority: undefined, 12 isSection: false, 13 isActive: true, 14 hidden: false,// ★ ナビに表示 15 }, 16 17 { 18 displayId: "M00000016", 19 parentId: "M00000011", 20 order: 1, 21 title: "新規登録", 22 href: "/users/new", 23 iconName: undefined, 24 match: "exact", 25 pattern: undefined, 26 minPriority: undefined, 27 isSection: false, 28 isActive: true, 29 hidden: true, // ★ ナビには出さないがRBAC判定には必要 30 }, 31 32 // プロフィール編集関連を追加(これは追加しないと後々ページが表示できなくなります) 33 { 34 displayId: "M00000019", 35 parentId: null, 36 order: 4, 37 title: "プロフィール編集", 38 href: "/profile", 39 iconName: undefined, 40 match: "exact", 41 pattern: undefined, 42 minPriority: undefined, 43 isSection: false, 44 isActive: true, 45 hidden: true, 46 }, 47 { 48 displayId: "M00000020", 49 parentId: "M00000019", 50 order: 0, 51 title: "メールアドレス変更", 52 href: "/profile/email", 53 iconName: undefined, 54 match: "exact", 55 pattern: undefined, 56 minPriority: undefined, 57 isSection: false, 58 isActive: true, 59 hidden: true, 60 }, 61 { 62 displayId: "M00000021", 63 parentId: "M00000019", 64 order: 1, 65 title: "パスワード変更", 66 href: "/profile/password", 67 iconName: undefined, 68 match: "exact", 69 pattern: undefined, 70 minPriority: undefined, 71 isSection: false, 72 isActive: true, 73 hidden: true, 74 }, 75 { 76 displayId: "M00000022", 77 parentId: "M00000019", 78 order: 2, 79 title: "メールアドレス変更の確認", 80 href: "/profile/email/verify", 81 iconName: undefined, 82 match: "exact", 83 pattern: undefined, 84 minPriority: undefined, 85 isSection: false, 86 isActive: true, 87 hidden: true, 88 }, 89
このように hidden を導入しておけば、
  • ナビゲーションに出す必要のないページ(例:ユーザ詳細や新規登録フォーム)
  • 直接URLを指定されたときだけアクセスさせたいページ
をシンプルに扱えるようになります。 本章のはじめにも記載しましたが、本記事では、 メニューの表示処理部分の修正は行わず、 別途メニューのDB連携の記事で行いますhiddenが指定されたメニューも本記事の段階ではサイドメニューに表示されたままになります)。
次の章では、ユーザ情報(user-snapshot)にロールの機能フラグを含める拡張を進めていきます。

3. user-snapshot.tsの拡張

この章では、 ログイン中ユーザの最小スナップショット に、ロール由来の
canEditData / canDownloadData を追加します。
これにより、 ページの表示可否(RBACガード)と、ページ内部の機能可否 を綺麗に分離できます。

現状と目標

現状の AuthUserSnapshot は、表示可否判定に必要な rolePriority までは含みますが、
ページ内部の操作可否(編集・ダウンロード) を表すフラグは未収載です。ここに2フラグを追加します。
項目現状追加後(目標)
表示可否(RBAC判定用)rolePriority変更なし
機能可否(ページ内部の操作)なしcanEditData, canDownloadData を追加
PII最小化name, email, avatarUrl 最小限変更なし(範囲内で据え置き)
txt
1┌──────────┐ ┌───────────────────────────┐ 2│ Role │──→ │ priority / can* フラグ │ 3└──────────┘ └─────────────┬─────────────┘ 45 ┌───────▼────────┐ 6 │ UserSnapshot │ 7 │ (最小情報) │ 8 └───┬────────────┘ 910 RBAC(表示可否) ────┘ └─── 機能制御(編集/DLの活性)

型定義の拡張(src/lib/auth/types.ts

AuthUserSnapshotcanEditData / canDownloadData を追加します。
AuthContextValue は据え置きでOKです(user に新プロパティが乗ります)。
ts
1// src/lib/auth/types.ts 2export type AuthUserSnapshot = { 3 userId: string; 4 name: string; 5 email: string; 6 avatarUrl: string | null; 7 roleCode: string; // ← Prisma の Role.code に一致 8 rolePriority: number; 9 // ★ 追加:ページ内部の機能制御フラグ(表示可否とは無関係) 10 canEditData: boolean; 11 canDownloadData: boolean; 12}; 13 14export type AuthContextValue = { 15 ready: boolean; // 初期化済みか 16 user: AuthUserSnapshot | null; // 未ログイン時は null 17 setUser: (user: AuthUserSnapshot | null) => void; // ログイン/ログアウトなどのクライアント操作から呼べるように 18 // 将来用:再同期フロー(ロール変更を反映したい等) 19 refresh?: () => Promise<void>; 20};
  • 型の破壊的変更なし :既存利用箇所はそのまま動作します(プロパティが増えるだけ)。
  • 以降、CSR側では useAuth().user?.canEditData などでボタンの活性/非活性が制御できます。

取得処理の拡張(src/lib/auth/user-snapshot.ts

Prisma の user.role から canEditData / canDownloadDataselect して返却 します。
既存方針どおり、 必要最小限の列のみ を取得します。
ts
1// src/lib/auth/user-snapshot.ts 2import { prisma } from "@/lib/database"; 3import type { AuthUserSnapshot } from "./types"; 4 5/** 6 * セッションで得られた userId から、Context 用の最小スナップショットを作る。 7 * PII を最小化し、重い JOIN は避け、必要な列だけ select する。 8 */ 9export async function getUserSnapshot( 10 userId: string, 11): Promise<AuthUserSnapshot | null> { 12 const user = await prisma.user.findUnique({ 13 where: { id: userId }, 14 select: { 15 id: true, 16 name: true, 17 email: true, 18 avatar: true, 19 role: { 20 select: { 21 code: true, 22 priority: true, 23 // ★ 追加:ページ内部機能フラグ 24 canEditData: true, 25 canDownloadData: true, 26 }, 27 }, 28 }, 29 }); 30 31 if (!user || !user.role) return null; 32 33 return { 34 userId: user.id, 35 name: user.name, 36 email: user.email, 37 avatarUrl: user.avatar ? `/avatar/${user.id}` : null, 38 roleCode: user.role.code, 39 rolePriority: user.role.priority, 40 // ★ 追加 41 canEditData: user.role.canEditData, 42 canDownloadData: user.role.canDownloadData, 43 }; 44}
  • 変更点は select と返却部のみ。既存の avatarUrl 生成・PII最小化方針はそのまま維持しています。
  • ここでは 表示可否は判定しません(ページの表示可否は次章の SSR ガードが担当)。

CSR/SSR での使い分け

CSR(クライアント)useAuth() から受け取った user に基づき、ボタンの活性/非活性を切り替えます。
SSR(サーバ) :表示ガードは別章のガード関数で実施し、クライアントへ initialUser を渡せます。
tsx
1// 例)任意のクライアントコンポーネント(CSRの活性制御) 2"use client"; 3import { Button } from "@/components/ui/button"; 4import { useAuth } from "@/lib/auth/context"; 5 6export function UsersToolbar() { 7 const { user } = useAuth(); 8 const canEdit = !!user?.canEditData; 9 const canDownload = !!user?.canDownloadData; 10 11 return ( 12 <div className="flex gap-2"> 13 <Button disabled={!canEdit}>保存</Button> 14 <Button disabled={!canDownload}>CSVダウンロード</Button> 15 </div> 16 ); 17}
  • 表示そのものは SSR ガードで弾かれるため、CSRでは 「表示後の操作」だけ を制御します。
  • これにより「見せない(SSR)」と「触らせない(CSR)」の二段ガードが成立します。

動作確認の観点(ハンドブック)

テスト観点を表にまとめておきます。最小の手動確認でも品質を担保できます。
観点条件期待結果
EDIT権限なしcanEditData=false保存ボタンが disabled
DOWNLOAD権限なしcanDownloadData=falseDLボタンが disabled
両方ありcanEditData=true, canDownloadData=true両ボタンが enabled
ロール変更直後(将来の再同期)refresh() 実行フラグが変更後の値で UI が再描画される
未ログインuser=null両ボタン disabled(または画面自体がSSRで非表示)
以上で user-snapshot.ts の拡張は完了です。
次章では SSR用ガード関数 を実装し、INITIAL_MENU_RECORDS(将来は Menu)の minPriority を参照して、ページの表示可否 を厳密に制御します。

4. ガード関数の設計

ここからは、実際に ページ単位でのアクセス制御(ガード処理) を設計します。
従来のクライアントサイド判定(useAuthなど)では不十分なため、SSR環境で動作する仕組みにします。
ガード関数は、次の要件を満たす必要があります。
要件説明
SSRで利用可能useAuth はクライアント限定なので不可。サーバサイドで判定できること。
Cookieからセッション復元lookupSessionFromCookie を利用してユーザ情報を取得する。
user-snapshot参照DBから必要最小限のユーザ情報を取得し、権限判定に利用する。
Menu定義との比較INITIAL_MENU_RECORDS(将来は Menu テーブル)と突き合わせて判定。
この章では、設計の流れを段階的に整理します。

useAuthではなくSSRで利用可能に

クライアント側の useAuth フックは SSR では動作しません。
そのため、 サーバコンポーネントやmiddleware内で利用できる関数 を新たに設計します。
ここで定義するガード関数は、 ページロード前に権限チェックを実行 し、未許可の場合は即リダイレクト/403エラーを返す形を取ります。
txt
1┌───────────────┐ 2│ クライアント │ useAuth() → ログイン状態のみ判定 │ 3├───────────────┤ 4│ サーバサイド │ ガード関数 → Cookie + DB情報で判定 │ 5└───────────────┘

lookupSessionFromCookieとuser-snapshotの組み合わせ

ユーザ判定は、以下の2段階で行います。
  1. lookupSessionFromCookie
    • Cookieからセッション情報を取得し、userId を取り出す。
    • セッションが無ければ即リダイレクト。
  2. getUserSnapshot
    • 取得した userId をキーに、DBからユーザ情報(roleCode, rolePriority, canEditDataなど)を最小限だけSELECT。
    • これにより、 RBAC判定に必要な情報を揃える
txt
1[Cookie] → lookupSessionFromCookie → userId 23getUserSnapshot(userId) → { rolePriority, canEditData, ... } 45ガード関数でMenu定義と比較

INITIAL_MENU_RECORDS(将来Menuテーブル)との比較

最後に、対象ページのURIを INITIAL_MENU_RECORDS から検索し、ユーザの権限と比較します。
この処理は将来的に Menu テーブルへ移行しますが、設計思想は変わりません。
比較対象判定内容
rolePriority vs minPriorityページに必要な最小権限を満たしているか。
isActive無効化されていないか。
hiddenナビには表示しないが、存在すればガード対象に含める。
このように整理しておくことで、 「ナビには出さないが守りたいページ」 も安全に保護できます。
次章では、このガード判定ロジックを具体的なコードとして実装していきます。

5. ガード判定ロジック

この章では、 URI とメニュー定義(INITIAL_MENU_RECORDS)を突き合わせて表示可否を決める ロジックを実装します。
ポイントは「 最良一致 の選定」「 祖先メニューの minPriority 継承 」「 メニュー未定義時の扱い 」です。

判定戦略の全体像

まず、URI とメニューをどう突き合わせるか、アルゴリズムを明確にします。
「最良一致(Best Match)」は下表の優先度で選び、 同じ種別でも“より長い”ものが勝つ とします。
優先順位マッチ種別説明具体例(URI=/users/abc/edit)
1exact完全一致/users/abc/edit がそのまま定義されている
2prefix先頭一致。より長い prefix が優先/users/(詳細群) > /users(一覧)
3regex正規表現一致。より長い pattern が優先(任意)^/users/[^/]+/edit$ など
txt
1URI: /users/abc/edit 2 ├─ exact: なし 3 ├─ prefix: /users/ ← より長い prefix(/users より優先) 4 └─ regex: ^/users/[^/]+/edit$ があればこちらが最良 5→ 結果: regex があれば regex、なければ「/users/」が最良一致

祖先メニューからの minPriority 継承

「表示するために必要な最小権限(minPriority)」は、 最良一致ノードから親方向へ遡って最大値を採用 します。
これにより、 親が厳しいなら子も厳しくなる という自然なモデルが成立します。
minPriority備考
/users30一覧ページ
/users/40詳細ページ群(hidden 可)
/users/*/edit60編集ページ(regex/exact などで指定)
URI=/users/abc/edit の場合:
最良一致(孫=60)と、その親(子=40)・祖先(親=30)を見て 最大値=60 を要求権限と確定。

メニュー未定義時の扱い

URI に該当するメニュー定義が見つからない場合 は、 拒否 / 401 or 403 / redirect で閉じます。
「定義が無いページは表示させない」という前提を強制し、 定義漏れのまま公開される事故 を防ぎます。

型とユーティリティの下準備

MenuRecordmenu.schema.ts で定義済み(hidden などを含む)を想定します。
判定に使うためのインデックス(byId / childrenByParentId)や、URI の祖先を列挙する関数を用意します。
auth回りの型定義は、すでにsrc/lib/auth/types.tsがあるので、これに集約します。
ts
1// src/lib/auth/types.ts(末尾に追記) 2 3export type GuardDecision = 4 | { ok: true; requiredPriority: number; matchedId: string | null } 5 | { ok: false; reason: "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" }; 6 7export type MatchKind = "exact" | "prefix" | "regex"; 8 9export type GuardOptions = { 10 /** 11 * true の場合は「未定義URI」を NOT_FOUND にする。 12 * false の場合は「/users → /」のようにパス祖先での探索も試みる(将来拡張用)。 13 */ 14 strictNotFound: boolean; 15};
ts
1// src/lib/auth/guard.util.ts 2import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 3 4/** 祖先URIを末尾から順に列挙(例: /a/b/c → ["/a/b/c", "/a/b", "/a", "/"]) */ 5export function enumerateAncestorHrefs(href: string): string[] { 6 const parts = href.split("/").filter(Boolean); // 空要素除去 7 const acc: string[] = []; 8 for (let i = parts.length; i >= 0; i--) { 9 const p = "/" + parts.slice(0, i).join("/"); 10 acc.push(p === "//" ? "/" : p); 11 } 12 return acc; 13} 14 15/** メニューの索引を作成 */ 16export function buildMenuIndex(records: MenuRecord[]) { 17 const byId = new Map<string, MenuRecord>(); 18 const childrenByParentId = new Map<string | null, MenuRecord[]>(); 19 20 for (const r of records) { 21 byId.set(r.displayId, r); 22 const bucket = childrenByParentId.get(r.parentId) ?? []; 23 bucket.push(r); 24 childrenByParentId.set(r.parentId, bucket); 25 } 26 27 // 子を order で安定ソート(任意) 28 for (const [k, arr] of childrenByParentId) { 29 arr.sort((a, b) => a.order - b.order); 30 childrenByParentId.set(k, arr); 31 } 32 33 return { byId, childrenByParentId }; 34}
  • enumerateAncestorHrefs は URI がメニューに定義されているかを探すための 補助 (最終的に拒否に使う)。
  • buildMenuIndex は親子関係を辿るために O(1) で親を引ける状態にしておきます。

最良一致の選択(exact > prefix(長) > regex(長))

hiddenRBAC 判定には“含める” 前提です(ナビには出さないが、守る対象)。
isActive=false は判定対象から 除外 します。
ts
1// src/lib/auth/guard.matcher.ts 2import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 3 4type Candidate = { 5 record: MenuRecord; 6 kind: "exact" | "prefix" | "regex"; 7 score: number; // 長いほど高スコアにする 8}; 9 10function regexSafe(pattern: string): RegExp | null { 11 try { 12 return new RegExp(pattern); 13 } catch { 14 return null; 15 } 16} 17 18/** URI に対する最良一致を選ぶ(hidden を含める・isActive=false は除外) */ 19export function pickBestMatch( 20 records: MenuRecord[], 21 href: string, 22): MenuRecord | null { 23 const active = records.filter((r) => r.isActive); 24 const cands: Candidate[] = []; 25 26 for (const r of active) { 27 if (!r.href && r.match !== "regex") continue; // 見出し等 28 if (r.match === "exact" && r.href === href) { 29 cands.push({ record: r, kind: "exact", score: Number.MAX_SAFE_INTEGER }); 30 continue; 31 } 32 if (r.match === "prefix" && r.href && href.startsWith(r.href)) { 33 cands.push({ record: r, kind: "prefix", score: r.href.length }); 34 continue; 35 } 36 if (r.match === "regex" && r.pattern) { 37 const re = regexSafe(r.pattern); 38 if (re && re.test(href)) { 39 cands.push({ record: r, kind: "regex", score: r.pattern.length }); 40 } 41 } 42 } 43 44 if (cands.length === 0) return null; 45 46 // 種別優先: exact > prefix > regex。種別内は score(長さ)で降順 47 const kindRank = { exact: 3, prefix: 2, regex: 1 } as const; 48 cands.sort((a, b) => { 49 const kr = kindRank[a.kind] - kindRank[b.kind]; 50 if (kr !== 0) return -kr; 51 return b.score - a.score; 52 }); 53 54 return cands[0].record; 55}
  • exact が最強、prefix長い方 を優先、regex長い pattern を優先。
  • hidden の有無は ナビ表示 にだけ影響し、ここ(RBAC判定)では 候補に含めます

親チェーンから要求権限を確定

最良一致ノードから親へ辿り、minPriority最大値 を「要求権限」として確定します。
minPriority が未設定(undefined)であれば、親からの継承 に任せます。
ts
1// src/lib/auth/guard.priority.ts 2import type { MenuRecord } from "@/lib/sidebar/menu.schema"; 3 4export function computeRequiredPriority( 5 start: MenuRecord, 6 byId: Map<string, MenuRecord>, 7): { required: number; chain: string[] } { 8 let max = -Infinity; 9 const chain: string[] = []; 10 11 // cur は「次に見るノード」:存在しなければ while を抜ける 12 let cur: MenuRecord | undefined = start; 13 14 while (cur) { 15 chain.push(cur.displayId); 16 17 if (typeof cur.minPriority === "number") { 18 max = Math.max(max, cur.minPriority); 19 } 20 21 // parentId は schema 上 `string | null`(undefined ではない) 22 const parentId: string | null = cur.parentId; 23 24 // ルート到達(親なし) 25 if (parentId === null) break; 26 27 // 次の親を引く(見つからなければ終了) 28 const next: MenuRecord | undefined = byId.get(parentId); 29 cur = next; 30 } 31 32 // どこにも minPriority が無ければ 0(全員可) 33 const required = Number.isFinite(max) ? max : 0; 34 return { required, chain }; 35}
  • チェーンで より厳しい値が優先 されるため、親 > 子 の整合性を自動的に保てます。
  • どこにも minPriority が無ければ 0(全員可) と扱います。

仕上げ:ガード判定の本体

ここまでのユーティリティを用いて、 URI → 最良一致 → 要求権限 → 可否 を一気に判定します。
セッションの有無 (401)と 権限不足 (403)を分けて返すようにします。
ts
1// src/lib/auth/guard.core.ts 2import type { 3 AuthUserSnapshot, 4 GuardDecision, 5 GuardOptions, 6} from "@/lib/auth/types"; 7import { buildMenuIndex, enumerateAncestorHrefs } from "./guard.util"; 8import { pickBestMatch } from "./guard.matcher"; 9import { computeRequiredPriority } from "./guard.priority"; 10import { INITIAL_MENU_RECORDS } from "@/lib/sidebar/menu.mock"; 11 12/** 13 * ガード判定のエントリポイント 14 * @param href ページの絶対パス(例: "/users/new") 15 * @param user 認証済みユーザ(未ログインなら null) 16 * @param options 未定義は拒否 を strict に適用するか 17 */ 18export function decideGuard( 19 href: string, 20 user: AuthUserSnapshot | null, 21 options: GuardOptions = { strictNotFound: true }, 22): GuardDecision { 23 // 1) 未ログイン 24 if (!user) { 25 return { ok: false, reason: "UNAUTHORIZED" }; 26 } 27 28 const records = INITIAL_MENU_RECORDS; // 将来は DB 置換 29 const { byId } = buildMenuIndex(records); 30 31 // 2) 最良一致を探す 32 const best = pickBestMatch(records, href); 33 34 // 2-a) 未定義URIの扱い 35 if (!best) { 36 if (options.strictNotFound) { 37 return { ok: false, reason: "NOT_FOUND" }; 38 } 39 // 将来の拡張: /a/b/c → /a/b → /a → / の順にフォールバック探索 40 for (const a of enumerateAncestorHrefs(href).slice(1)) { 41 const b = pickBestMatch(records, a); 42 if (b) { 43 const { required } = computeRequiredPriority(b, byId); 44 return user.rolePriority >= required 45 ? { ok: true, requiredPriority: required, matchedId: b.displayId } 46 : { ok: false, reason: "FORBIDDEN" }; 47 } 48 } 49 return { ok: false, reason: "NOT_FOUND" }; 50 } 51 52 // 3) 祖先を含めて必要権限を確定 53 const { required } = computeRequiredPriority(best, byId); 54 55 // 4) 比較 56 if (user.rolePriority >= required) { 57 return { ok: true, requiredPriority: required, matchedId: best.displayId }; 58 } 59 return { ok: false, reason: "FORBIDDEN" }; 60}
  • 401(UNAUTHORIZED) :セッション未確立。ログインページへリダイレクト。
  • 403(FORBIDDEN) :ログイン済みだが 権限不足 。403ページへ、または一覧へ戻す等。
  • 404相当(NOT_FOUND)メニュー未定義 。意図しない公開を防ぎます。

補助:SSR用ヘルパ(redirect組み合わせ)

next/navigationredirect() と併用しやすいヘルパを用意しておくと、page.tsx が簡潔になります。
ts
1// src/lib/auth/guard.ssr.ts 2import { redirect } from "next/navigation"; 3import { lookupSessionFromCookie } from "@/lib/auth/session"; 4import { getUserSnapshot } from "@/lib/auth/user-snapshot"; 5import { decideGuard } from "./guard.core"; 6 7/** 成功時は UserSnapshot を返し、失敗時は redirect/エラーで終了 */ 8export async function guardHrefOrRedirect(href: string, loginPath = "/") { 9 const session = await lookupSessionFromCookie(); 10 11 if (!session.ok) { 12 redirect(loginPath); // 401相当 13 } 14 15 const user = await getUserSnapshot(session.userId); 16 if (!user) { 17 redirect(loginPath); 18 } 19 20 const decision = decideGuard(href, user); 21 if (!decision.ok) { 22 if (decision.reason === "UNAUTHORIZED") redirect(loginPath); 23 if (decision.reason === "FORBIDDEN") redirect("/403"); 24 if (decision.reason === "NOT_FOUND") redirect("/404"); 25 } 26 27 return user; // page.tsx から機能フラグ等も参照可能 28}
  • ここまでで サーバサイドだけで完結 するガードが整いました。
  • page.tsx は「URI を定数で渡すだけ」で RBAC を適用できます(次章で実例化)。

例外系・運用Tips

実装でハマりがちな点と対策を表にまとめます。
罠/論点原因対策
hidden を候補から外してしまう「非表示=判定不要」と誤解hidden も RBAC 判定には含める (ナビ表示だけ除外)
minPriority が未設定のノードで緩くなる子にだけ設定して親に設定し忘れ祖先の最大値 を採用。必要なら親にも明示して設計意図を固定化
regex が prefix より先に拾われる優先順未定義exact > prefix > regex の優先順で安定化
メニュー未定義のページが表示されるフォールバックが緩すぎる未定義=拒否 を既定。必要時のみフォールバックを明示的にON
CSRでボタンだけ無効にして満足してしまう表示ガードをCSRに任せているSSRで弾く (CSRは“表示後の機能制御”に限定)
これで、 URI→最良一致→祖先継承→可否 の一連の流れが実装できました。
次章では、このガードを 実際の page.tsx へ組み込み/users/new を例に挙動を確認します。

6. page.tsxへの組み込み例

この章では、実装した SSRガード関数 を実際の page.tsx に組み込み、
未ログイン・権限不足・メニュー未定義の各ケースで正しく制御できることを確認します。
サンプルは「ユーザ新規作成」ページ /users/new を対象にします。

/users/new の実装例

guardHrefOrRedirect()page.tsx の先頭で呼び出し、表示前に判定します。
OK のときのみレンダリングを続行し、NG のときはその場で redirect() されます(401/403/404を自動処理)。
これまで、Cookieからセッション確認(lookupSessionFromCookie())を各ページで行ってきましたが、これがguardHrefOrRedirect()に集約されて下記のようになります。
tsx
1// src/app/(protected)/users/new/page.tsx 2 3// import { redirect } from "next/navigation"; // 不要 4// import { lookupSessionFromCookie } from "@/lib/auth/session"; // 不要 5 6import type { Metadata } from "next"; 7 8import { 9 Breadcrumb, 10 BreadcrumbItem, 11 BreadcrumbLink, 12 BreadcrumbList, 13 BreadcrumbPage, 14 BreadcrumbSeparator, 15} from "@/components/ui/breadcrumb"; 16import { Separator } from "@/components/ui/separator"; 17import { SidebarTrigger } from "@/components/ui/sidebar"; 18import Client from "./client"; 19import { mockRoleOptions } from "@/lib/users/mock"; 20 21// ★ 追加:SSRガード 22import { guardHrefOrRedirect } from "@/lib/auth/guard.ssr"; 23 24const ACCOUNT_CODE = "testAccount0123"; // UIのみの仮固定 25 26export const metadata: Metadata = { 27 title: "ユーザ新規登録", 28 description: 29 "共通フォーム(shadcn/ui + React Hook Form + Zod)でユーザを新規作成", 30}; 31 32export default async function Page() { 33 // ここが不要 34 // const session = await lookupSessionFromCookie(); 35 // if (!session.ok) { 36 // redirect("/"); // 未ログインはログインページ(/)へ 37 // } 38 39 // ★ ここで表示可否を判定(未ログイン/権限不足/未定義は内部でredirect) 40 await guardHrefOrRedirect("/users/new", "/"); 41 42 return ( 43 <> 44 <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"> 45 <div className="flex items-center gap-2 px-4"> 46 <SidebarTrigger className="-ml-1" /> 47 <Separator 48 orientation="vertical" 49 className="mr-2 data-[orientation=vertical]:h-4" 50 /> 51 <Breadcrumb> 52 <BreadcrumbList> 53 <BreadcrumbItem className="hidden md:block"> 54 <BreadcrumbLink href="/users">ユーザ管理</BreadcrumbLink> 55 </BreadcrumbItem> 56 <BreadcrumbSeparator className="hidden md:block" /> 57 <BreadcrumbItem> 58 <BreadcrumbPage>ユーザ新規登録</BreadcrumbPage> 59 </BreadcrumbItem> 60 </BreadcrumbList> 61 </Breadcrumb> 62 </div> 63 </header> 64 65 <div className="max-w-xl p-4 pt-0"> 66 <Client roleOptions={mockRoleOptions} accountCode={ACCOUNT_CODE} /> 67 </div> 68 </> 69 ); 70}
  • guardHrefOrRedirect("/users/new")表示直前 に実行され、OK なら AuthUserSnapshot を返します。
  • クライアント側の機能制御(編集・DLボタンの活性)を行いたい場合は、user.canEditData / user.canDownloadDataClient へ props で渡すだけでOKです(CSRでは useAuth() を使ってもよい)。
  • 既存のパンくず/UI構造はそのまま活かせます。

SSRでの動作確認

/users/new に対し、それぞれの条件での遷移を表にまとめます。
INITIAL_MENU_RECORDS(将来は Menu)に /users/newhidden: true でも、 RBAC 判定の候補には含まれる ことに注意してください。
状態前提期待挙動
未ログインCookie にセッションなし/ へ redirect(401相当)
ログイン済・権限不足rolePriority < requiredPriority/403 へ redirect(403)
メニュー未定義/users/new が定義されていない(親フォールバックもしない)/404 へ redirect(404相当)
ログイン・権限十分rolePriority >= requiredPriorityページ表示(Client が描画される)
hidden: true/users/new が hidden でも isActive: true で定義ありナビ非表示だが、RBAC判定は対象

リクエスト〜判定〜描画の流れ(概念図)

ガードは SSR段階 で実行され、NGならレンダリング前に遮断 されます。
CSRの useAuth は、描画後の機能制御 に限定して使うのがポイントです。
txt
1[Request /users/new] 234 guardHrefOrRedirect("/users/new") 5 │ ├─ lookupSessionFromCookie(401?) 6 │ ├─ getUserSnapshot(rolePriority, can*) 7 │ └─ decideGuard(INITIAL_MENU_RECORDS と照合) 8 │ ├─ pickBestMatch(exact > prefix > regex) 9 │ └─ computeRequiredPriority(祖先max) 1011 ├─ NG → redirect(/, /403, /404) 12 └─ OK → user を返却 131415 page.tsx(SSR) → Client(CSR:can*で活性制御)

実装のワンポイント(運用Tips)

  • URIのハードコードpage.tsx 側で guardHrefOrRedirect("/users/new")引数URIを明示 することで、
    ルーティングと判定対象がズレないようにします(将来、共通化ヘルパで自動化も可)。
  • hidden の使い所/users/new/users/[displayId] のような「操作画面/詳細画面」を ナビから隠す が、
    RBACでは保護 したい場合に有効です。
  • 403/404 ページapp/403/page.tsxapp/404/page.tsx を用意して、
    ガイド文言や戻りリンクを設置すると UX が向上します。現状ではsrc/app/(protected)/not-found.tsxの内容が表示されます。
少し面倒ですが、src/app/(protected)/users/new/page.tsxと同様に、ログイン後の各ページ ( (protected)/配下のpage.tsx)についても同様の変更を行います。
src/app/(protected)/users/[displayId]/page.tsxのようなページの場合はawait guardHrefOrRedirect(/users/${displayId}, "/");のように組み込みます。
この組み込みにより、「見せない(SSRガード)」×「触らせない(CSRのcan*)」 の二層で安全にページを保護できます。
▼ADMIN権限以外のユーザで/users/newを表示した場合の画面
権限なしの場合の画面
/403が表示されるようになりますので、お好みでapp/403/page.tsxを作成すると専用のメッセージを出すことも可能です。
次章では、この仕組みをベースに 将来のDB連携(Menuテーブル) へスムーズに移行するための設計ポイントを整理します。

7. 将来のDB連携への布石

ここまでの実装は INITIAL_MENU_RECORDS という モック配列 を前提にしてきました。
これは「UIデモや仕様確認」には十分ですが、実運用を考えると DB(Menuテーブル)との連携 が不可欠になります。
この章では、将来DBに移行する際の布石として、 何が変わり、何が変わらないのか を整理しておきます。

Menuテーブルを利用した場合

Prismaで定義した Menu モデルを直接利用すれば、メニューの構成を管理画面から編集できるようになります。
現状の「静的配列」との違いを表にまとめると次のようになります。
項目現状(INITIAL_MENU_RECORDS)将来(Menuテーブル)
データの格納場所ソースコード(TypeScript配列)PostgreSQL(Prismaモデル Menu)
更新方法コード修正+デプロイ管理画面のUI操作(DB更新)
hiddenの扱い配列要素に boolean を直書きDBの hidden カラムで制御
isActiveの扱い配列要素に boolean を直書きDBの isActive カラムで制御(論理削除対応)
順序管理order フィールドsortOrder カラム
権限判定minPriority 直値minPriority カラム
運用負荷高い(リリース作業必須)低い(管理画面で即時反映)
移行の際には、 配列を返していた箇所を Prisma クエリに置き換えるだけ で基本的に対応可能です。

hiddenページの扱いと設計指針

hidden フラグの導入によって、ナビゲーションに出さないが保護したいページ を管理できるようになりました。
DB化した後もこの設計は維持すべきです。
実際の利用シーンを例にすると次の通りです。
ページ種別hidden用途のイメージ
一覧ページ(/users)falseナビに表示する通常の遷移先
新規作成ページ(/users/new)true一覧から遷移はできるが、ナビ自体には表示しない
詳細ページ(/users/[id])true個別リンクでのみ到達。直接アクセスされた場合もRBACで守る対象
このように「ナビには不要だが守るべきURI」をDBに登録することで、
RBACの整合性を失わずに運用できる ようになります。

今後の移行ステップ

最後に、配列からDBへ移行するまでの大まかなロードマップを整理しておきます。
ステップ内容本記事での進捗
1Menuモデルに hidden カラムを追加✔️完了
2INITIAL_MENU_RECORDS に hidden を明示的に設定✔️完了
3RBACガード処理を MenuRecord 型依存で統一✔️完了
4Prismaから Menu.findMany を取得するコードに差し替え次回以降の記事
5管理画面から Menu テーブルを編集可能にする次回以降の記事
こうして布石を打っておくことで、 配列→DB への移行時も大きな破壊的変更は不要になります。
RBACガードの仕組みはそのまま流用でき、データソースだけを差し替える形になります。

8. まとめと次回予告

ここまでで、管理画面フォーマットにおける RBAC調整(ページ単位のアクセス制御) を実装しました。
「メニュー表示に限定されていた制御」を、 SSRでのガード関数 を通じて各ページに拡張し、
未ログイン・権限不足・未定義ページをそれぞれ適切に拒否できるようになりました。

今回実装した内容の整理

今回の記事で対応したポイントを、表形式で整理します。
項目実装内容効果
Menuモデルへの hidden追加ナビには出さないが RBAC 判定対象とするページを扱えるようにしたユーザ詳細や新規登録ページの安全な保護が可能
user-snapshot拡張canEditData / canDownloadData を追加表示可否と機能可否を分離、UIの活性制御に活用
ガード関数の設計SSRで lookupSessionFromCookie + getUserSnapshot を利用Cookie + DB 情報を元にサーバ側で制御
判定ロジックURIに最良一致させ、親メニューの minPriority を継承一覧・詳細・編集ページなど階層的な権限制御
page.tsxへの組み込みguardHrefOrRedirect を呼び出してリダイレクト制御各ページがシンプルにアクセス制御可能
これにより、「見せない」「守る」「触らせない」 の三段階で安全性を担保できる基盤が整いました。

次回予告:ユーザ管理のDB連携

次回は、これまで モックデータ で構築してきたユーザ一覧・新規登録・編集フォームを、
実際の Prisma + PostgreSQL の Userテーブル に接続します。
これにより、ユーザの作成や更新、一覧表示を本物のデータベースと連携させられるようになります。
次回の主な作業説明
UserテーブルのマイグレーションPrisma schema に沿って User テーブルをDBに適用
一覧ページのDB接続/users ページを Prisma 経由でデータ取得
新規作成フォームのDB保存/users/new からのPOSTを実データとして永続化
編集フォームのDB更新/users/[displayId] ページで既存ユーザを編集
RBACとの統合作成・編集が canEditData のフラグに従うかを確認
ここから先は、管理画面が実際の業務アプリとして稼働するための重要フェーズ になります。
RBACで守られた「実データの操作」を安全に進める準備を整えていきます。

参考文献

今回の記事で参照した公式ドキュメントや関連資料を以下にまとめます。
RBACやNext.jsのガード設計に関する理解を深める際に活用してください。
分類リンク内容概要
Next.js App RouterNext.js App Router DocumentationApp Router の構成、SSR/CSR の挙動
Next.js NavigationNext.js – redirect()redirect() の使い方と SSR ガードでの活用
React ContextReact – ContextAuthContext 実装で利用する React Context API
PrismaPrisma DocumentationPrisma schema 定義、migration、selectの利用
ZodZod DocumentationZod によるスキーマ定義とバリデーション
RBAC 基礎Role-Based Access Control (Wikipedia)RBAC の基本概念とアクセス制御モデル
TypeScriptTypeScript Handbook型推論やユーティリティ型、strict モードの挙動
これらの資料を通して、 「UIでの非表示」だけでなく「サーバ側での保護」まで含めた RBAC 実装 の背景を体系的に理解できます。
特に Prisma と Next.js の組み合わせは実運用で頻出するため、公式ドキュメントを参照しながら適宜アップデートしていくのが有効です。
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。