![[管理画面フォーマット開発編 #6] RBAC調整 ─ ページ単位のアクセス制御を実装する](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-rbac-guard%2Fhero.jpg&w=3840&q=75)
管理画面フォーマット開発編 #6RBAC調整 ─ ページ単位のアクセス制御を実装する
これまでメニュー表示に適用していたRBACを、各ページのアクセス制御に拡張
初回公開日
最終更新日
0. はじめに
これまでの記事シリーズでは、管理画面における RBAC(Role-Based Access Control) を「メニュー表示」に限定して実装してきました。
具体的には、ユーザの
具体的には、ユーザの
rolePriority
と各メニューの minPriority
を比較し、十分な権限を持つユーザだけが該当メニューを表示できる仕組みです。しかし実際の運用では、URLを直接指定したアクセス や、メニューに載せていない隠しページ に対してもアクセス制御を行う必要があります。
今回の記事では、この RBAC の仕組みを ページ単位のアクセス制御 に拡張し、SSRで動作する
今回の記事では、この RBAC の仕組みを ページ単位のアクセス制御 に拡張し、SSRで動作する
page.tsx
にも組み込めるようにします。技術スタック
Tool / Lib | Version | Purpose |
---|---|---|
React | 19.x | UIの土台。コンポーネント/フックで状態と表示を組み立てる |
Next.js | 15.x | フルスタックFW。App Router/SSR/SSG、動的ルーティング、メタデータ管理 |
TypeScript | 5.x | 型安全・補完・リファクタリング |
shadcn/ui | latest | RadixベースのUIキット |
Tailwind CSS | 4.x | ユーティリティファーストCSSで素早くスタイリング |
Zod | 4.x | スキーマ定義と実行時バリデーション |
本記事では、前回の記事 【管理画面フォーマット開発編 #5】ユーザプロフィール更新 までのソースコードを引き継いで追加・編集していきます。
1. RBAC仕様の整理
RBAC(Role-Based Access Control)の仕組みを、管理画面全体でどのように適用するかを整理します。
ここでは「メニューとminPriority」「ユーザのrolePriority」「ページ内部機能のフラグ」の3つに分けて解説します。
ここでは「メニューとminPriority」「ユーザのrolePriority」「ページ内部機能のフラグ」の3つに分けて解説します。
メニューとminPriority
メニューごとに設定される
ページ単位でのガード処理も、この
minPriority
は、そのメニューや対応するページを表示するために必要な最小権限を示します。ページ単位でのガード処理も、この
minPriority
を基準にして判定します。txt
1┌────────────┬──────────────────────────┐
2│ 項目 │ 意味 │
3├────────────┼──────────────────────────┤
4│ minPriority │ ページやメニューに必要な権限 │
5│ rolePriority│ ユーザが保持している権限値 │
6└────────────┴──────────────────────────┘
たとえば、あるメニューの
未満であればメニュー非表示やページアクセス拒否となります。
minPriority = 50
の場合、ユーザの rolePriority
が 50以上であればアクセス可能です。未満であればメニュー非表示やページアクセス拒否となります。
ユーザのrolePriority
各ユーザには
これをログイン時にDBから取得し、
Role
が割り当てられており、その priority
値が rolePriority
として扱われます。これをログイン時にDBから取得し、
AuthContext
や userSnapshot
を通じてアプリ内で参照可能にします。txt
1┌────────────┬───────────────┐
2│ Role名 │ priority値 │
3├────────────┼───────────────┤
4│ ADMIN │ 100 │
5│ EDITOR │ 50 │
6│ VIEWER │ 10 │
7└────────────┴───────────────┘
このように数値化することで、メニューやページに対するアクセス制御を一元的に判定できます。
canEditData / canDownloadDataとの関係
Role には
これらは ページの表示可否には関与せず 、ページ内部の操作(編集ボタンの有効化やCSV出力の許可など)を制御するために用います。
canEditData
や canDownloadData
といった機能フラグも定義されています。これらは ページの表示可否には関与せず 、ページ内部の操作(編集ボタンの有効化やCSV出力の許可など)を制御するために用います。
txt
1┌───────────────┬─────────────────────────────┐
2│ 判定対象 │ 制御内容 │
3├───────────────┼─────────────────────────────┤
4│ rolePriority │ ページ表示の可否(ガード) │
5│ canEditData │ 編集フォームや保存処理の有効化 │
6│ canDownloadData│ CSVダウンロード等の機能利用可否 │
7└───────────────┴─────────────────────────────┘
この整理により、RBACは次のように二層で機能します。
- ページ単位の表示可否 → rolePriority と minPriority の比較
- ページ内部の機能制御 → canEditData / canDownloadData のフラグ参照
これを明確に分けて実装することで、アプリ全体の構造が分かりやすくなり、将来の拡張にも対応しやすくなります。
2. Menuモデルへのhidden追加
今回の記事では、まだDBの
しかし、将来的に
Menu
テーブルは利用していません。しかし、将来的に
INITIAL_MENU_RECORDS
を Menu
テーブルへ移行することを見据えて、 あらかじめ hidden
カラムを追加 しておきます。このカラムを用いることで、 ナビゲーションに表示しないがRBAC判定には必要なページ を扱えるようになります。
なお、本記事では、 メニューの表示処理部分の修正は行わず、 別途メニューのDB連携の記事で行います (
hidden
が指定されたメニューも本記事の段階ではサイドメニューに表示されたままになります)。prisma/schema.prismaの修正
model Menu
に hidden
カラムを追加します。既定値は
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
に追加します。menuUpdateSchema
は menuCreateSchema.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の拡張
この章では、 ログイン中ユーザの最小スナップショット に、ロール由来の
これにより、 ページの表示可否(RBACガード)と、ページ内部の機能可否 を綺麗に分離できます。
canEditData
/ canDownloadData
を追加します。これにより、 ページの表示可否(RBACガード)と、ページ内部の機能可否 を綺麗に分離できます。
現状と目標
現状の
ページ内部の操作可否(編集・ダウンロード) を表すフラグは未収載です。ここに2フラグを追加します。
AuthUserSnapshot
は、表示可否判定に必要な rolePriority
までは含みますが、ページ内部の操作可否(編集・ダウンロード) を表すフラグは未収載です。ここに2フラグを追加します。
項目 | 現状 | 追加後(目標) |
---|---|---|
表示可否(RBAC判定用) | rolePriority | 変更なし |
機能可否(ページ内部の操作) | なし | canEditData , canDownloadData を追加 |
PII最小化 | name , email , avatarUrl 最小限 | 変更なし(範囲内で据え置き) |
txt
1┌──────────┐ ┌───────────────────────────┐
2│ Role │──→ │ priority / can* フラグ │
3└──────────┘ └─────────────┬─────────────┘
4 │
5 ┌───────▼────────┐
6 │ UserSnapshot │
7 │ (最小情報) │
8 └───┬────────────┘
9 │
10 RBAC(表示可否) ────┘ └─── 機能制御(編集/DLの活性)
型定義の拡張(src/lib/auth/types.ts
)
AuthUserSnapshot
に canEditData
/ 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
/ canDownloadData
を select して返却 します。既存方針どおり、 必要最小限の列のみ を取得します。
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(クライアント) :
SSR(サーバ) :表示ガードは別章のガード関数で実施し、クライアントへ
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=false | DLボタンが disabled |
両方あり | canEditData=true, canDownloadData=true | 両ボタンが enabled |
ロール変更直後(将来の再同期) | refresh() 実行 | フラグが変更後の値で UI が再描画される |
未ログイン | user=null | 両ボタン disabled(または画面自体がSSRで非表示) |
以上で
次章では SSR用ガード関数 を実装し、
user-snapshot.ts
の拡張は完了です。次章では SSR用ガード関数 を実装し、
INITIAL_MENU_RECORDS
(将来は Menu
)の minPriority
を参照して、ページの表示可否 を厳密に制御します。4. ガード関数の設計
ここからは、実際に ページ単位でのアクセス制御(ガード処理) を設計します。
従来のクライアントサイド判定(useAuthなど)では不十分なため、SSR環境で動作する仕組みにします。
従来のクライアントサイド判定(useAuthなど)では不十分なため、SSR環境で動作する仕組みにします。
ガード関数は、次の要件を満たす必要があります。
要件 | 説明 |
---|---|
SSRで利用可能 | useAuth はクライアント限定なので不可。サーバサイドで判定できること。 |
Cookieからセッション復元 | lookupSessionFromCookie を利用してユーザ情報を取得する。 |
user-snapshot参照 | DBから必要最小限のユーザ情報を取得し、権限判定に利用する。 |
Menu定義との比較 | INITIAL_MENU_RECORDS (将来は Menu テーブル)と突き合わせて判定。 |
この章では、設計の流れを段階的に整理します。
useAuthではなくSSRで利用可能に
クライアント側の
そのため、 サーバコンポーネントやmiddleware内で利用できる関数 を新たに設計します。
useAuth
フックは SSR では動作しません。そのため、 サーバコンポーネントやmiddleware内で利用できる関数 を新たに設計します。
ここで定義するガード関数は、 ページロード前に権限チェックを実行 し、未許可の場合は即リダイレクト/403エラーを返す形を取ります。
txt
1┌───────────────┐
2│ クライアント │ useAuth() → ログイン状態のみ判定 │
3├───────────────┤
4│ サーバサイド │ ガード関数 → Cookie + DB情報で判定 │
5└───────────────┘
lookupSessionFromCookieとuser-snapshotの組み合わせ
ユーザ判定は、以下の2段階で行います。
-
lookupSessionFromCookie
- Cookieからセッション情報を取得し、
userId
を取り出す。 - セッションが無ければ即リダイレクト。
- Cookieからセッション情報を取得し、
-
getUserSnapshot
- 取得した
userId
をキーに、DBからユーザ情報(roleCode, rolePriority, canEditDataなど)を最小限だけSELECT。 - これにより、 RBAC判定に必要な情報を揃える 。
- 取得した
txt
1[Cookie] → lookupSessionFromCookie → userId
2 ↓
3getUserSnapshot(userId) → { rolePriority, canEditData, ... }
4 ↓
5ガード関数でMenu定義と比較
INITIAL_MENU_RECORDS(将来Menuテーブル)との比較
最後に、対象ページのURIを
この処理は将来的に
INITIAL_MENU_RECORDS
から検索し、ユーザの権限と比較します。この処理は将来的に
Menu
テーブルへ移行しますが、設計思想は変わりません。比較対象 | 判定内容 |
---|---|
rolePriority vs minPriority | ページに必要な最小権限を満たしているか。 |
isActive | 無効化されていないか。 |
hidden | ナビには表示しないが、存在すればガード対象に含める。 |
このように整理しておくことで、 「ナビには出さないが守りたいページ」 も安全に保護できます。
次章では、このガード判定ロジックを具体的なコードとして実装していきます。
5. ガード判定ロジック
この章では、 URI とメニュー定義(
ポイントは「 最良一致 の選定」「 祖先メニューの
INITIAL_MENU_RECORDS
)を突き合わせて表示可否を決める ロジックを実装します。ポイントは「 最良一致 の選定」「 祖先メニューの
minPriority
継承 」「 メニュー未定義時の扱い 」です。判定戦略の全体像
まず、URI とメニューをどう突き合わせるか、アルゴリズムを明確にします。
「最良一致(Best Match)」は下表の優先度で選び、 同じ種別でも“より長い”ものが勝つ とします。
「最良一致(Best Match)」は下表の優先度で選び、 同じ種別でも“より長い”ものが勝つ とします。
優先順位 | マッチ種別 | 説明 | 具体例(URI=/users/abc/edit) |
---|---|---|---|
1 | exact | 完全一致 | /users/abc/edit がそのまま定義されている |
2 | prefix | 先頭一致。より長い prefix が優先 | /users/ (詳細群) > /users (一覧) |
3 | regex | 正規表現一致。より長い 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 | 備考 |
---|---|---|
親 /users | 30 | 一覧ページ |
子 /users/ | 40 | 詳細ページ群(hidden 可) |
孫 /users/*/edit | 60 | 編集ページ(regex/exact などで指定) |
URI=/users/abc/edit の場合:
最良一致(孫=60)と、その親(子=40)・祖先(親=30)を見て 最大値=60 を要求権限と確定。
最良一致(孫=60)と、その親(子=40)・祖先(親=30)を見て 最大値=60 を要求権限と確定。
メニュー未定義時の扱い
URI に該当するメニュー定義が見つからない場合 は、 拒否 / 401 or 403 / redirect で閉じます。
「定義が無いページは表示させない」という前提を強制し、 定義漏れのまま公開される事故 を防ぎます。
「定義が無いページは表示させない」という前提を強制し、 定義漏れのまま公開される事故 を防ぎます。
型とユーティリティの下準備
MenuRecord
は menu.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(長))
hidden
は RBAC 判定には“含める” 前提です(ナビには出さないが、守る対象)。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)を分けて返すようにします。
セッションの有無 (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/navigation
の redirect()
と併用しやすいヘルパを用意しておくと、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.canDownloadData
をClient
へ props で渡すだけでOKです(CSRではuseAuth()
を使ってもよい)。 - 既存のパンくず/UI構造はそのまま活かせます。
SSRでの動作確認
/users/new
に対し、それぞれの条件での遷移を表にまとめます。INITIAL_MENU_RECORDS
(将来は Menu
)に /users/new
が hidden: 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の
CSRの
useAuth
は、描画後の機能制御 に限定して使うのがポイントです。txt
1[Request /users/new]
2 │
3 ▼
4 guardHrefOrRedirect("/users/new")
5 │ ├─ lookupSessionFromCookie(401?)
6 │ ├─ getUserSnapshot(rolePriority, can*)
7 │ └─ decideGuard(INITIAL_MENU_RECORDS と照合)
8 │ ├─ pickBestMatch(exact > prefix > regex)
9 │ └─ computeRequiredPriority(祖先max)
10 │
11 ├─ NG → redirect(/, /403, /404)
12 └─ OK → user を返却
13 │
14 ▼
15 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.tsx
、app/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連携への布石
ここまでの実装は
これは「UIデモや仕様確認」には十分ですが、実運用を考えると DB(Menuテーブル)との連携 が不可欠になります。
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の整合性を失わずに運用できる ようになります。
RBACの整合性を失わずに運用できる ようになります。
今後の移行ステップ
最後に、配列からDBへ移行するまでの大まかなロードマップを整理しておきます。
ステップ | 内容 | 本記事での進捗 |
---|---|---|
1 | Menuモデルに hidden カラムを追加 | ✔️完了 |
2 | INITIAL_MENU_RECORDS に hidden を明示的に設定 | ✔️完了 |
3 | RBACガード処理を MenuRecord 型依存で統一 | ✔️完了 |
4 | Prismaから Menu.findMany を取得するコードに差し替え | 次回以降の記事 |
5 | 管理画面から Menu テーブルを編集可能にする | 次回以降の記事 |
こうして布石を打っておくことで、 配列→DB への移行時も大きな破壊的変更は不要になります。
RBACガードの仕組みはそのまま流用でき、データソースだけを差し替える形になります。
RBACガードの仕組みはそのまま流用でき、データソースだけを差し替える形になります。
8. まとめと次回予告
ここまでで、管理画面フォーマットにおける RBAC調整(ページ単位のアクセス制御) を実装しました。
「メニュー表示に限定されていた制御」を、 SSRでのガード関数 を通じて各ページに拡張し、
未ログイン・権限不足・未定義ページをそれぞれ適切に拒否できるようになりました。
「メニュー表示に限定されていた制御」を、 SSRでのガード関数 を通じて各ページに拡張し、
未ログイン・権限不足・未定義ページをそれぞれ適切に拒否できるようになりました。
今回実装した内容の整理
今回の記事で対応したポイントを、表形式で整理します。
項目 | 実装内容 | 効果 |
---|---|---|
Menuモデルへの hidden 追加 | ナビには出さないが RBAC 判定対象とするページを扱えるようにした | ユーザ詳細や新規登録ページの安全な保護が可能 |
user-snapshot拡張 | canEditData / canDownloadData を追加 | 表示可否と機能可否を分離、UIの活性制御に活用 |
ガード関数の設計 | SSRで lookupSessionFromCookie + getUserSnapshot を利用 | Cookie + DB 情報を元にサーバ側で制御 |
判定ロジック | URIに最良一致させ、親メニューの minPriority を継承 | 一覧・詳細・編集ページなど階層的な権限制御 |
page.tsxへの組み込み | guardHrefOrRedirect を呼び出してリダイレクト制御 | 各ページがシンプルにアクセス制御可能 |
これにより、「見せない」「守る」「触らせない」 の三段階で安全性を担保できる基盤が整いました。
次回予告:ユーザ管理のDB連携
次回は、これまで モックデータ で構築してきたユーザ一覧・新規登録・編集フォームを、
実際の Prisma + PostgreSQL の Userテーブル に接続します。
これにより、ユーザの作成や更新、一覧表示を本物のデータベースと連携させられるようになります。
実際の Prisma + PostgreSQL の Userテーブル に接続します。
これにより、ユーザの作成や更新、一覧表示を本物のデータベースと連携させられるようになります。
次回の主な作業 | 説明 |
---|---|
Userテーブルのマイグレーション | Prisma schema に沿って User テーブルをDBに適用 |
一覧ページのDB接続 | /users ページを Prisma 経由でデータ取得 |
新規作成フォームのDB保存 | /users/new からのPOSTを実データとして永続化 |
編集フォームのDB更新 | /users/[displayId] ページで既存ユーザを編集 |
RBACとの統合 | 作成・編集が canEditData のフラグに従うかを確認 |
ここから先は、管理画面が実際の業務アプリとして稼働するための重要フェーズ になります。
RBACで守られた「実データの操作」を安全に進める準備を整えていきます。
RBACで守られた「実データの操作」を安全に進める準備を整えていきます。
参考文献
今回の記事で参照した公式ドキュメントや関連資料を以下にまとめます。
RBACやNext.jsのガード設計に関する理解を深める際に活用してください。
RBACやNext.jsのガード設計に関する理解を深める際に活用してください。
分類 | リンク | 内容概要 |
---|---|---|
Next.js App Router | Next.js App Router Documentation | App Router の構成、SSR/CSR の挙動 |
Next.js Navigation | Next.js – redirect() | redirect() の使い方と SSR ガードでの活用 |
React Context | React – Context | AuthContext 実装で利用する React Context API |
Prisma | Prisma Documentation | Prisma schema 定義、migration、selectの利用 |
Zod | Zod Documentation | Zod によるスキーマ定義とバリデーション |
RBAC 基礎 | Role-Based Access Control (Wikipedia) | RBAC の基本概念とアクセス制御モデル |
TypeScript | TypeScript Handbook | 型推論やユーティリティ型、strict モードの挙動 |
これらの資料を通して、 「UIでの非表示」だけでなく「サーバ側での保護」まで含めた RBAC 実装 の背景を体系的に理解できます。
特に Prisma と Next.js の組み合わせは実運用で頻出するため、公式ドキュメントを参照しながら適宜アップデートしていくのが有効です。
特に Prisma と Next.js の組み合わせは実運用で頻出するため、公式ドキュメントを参照しながら適宜アップデートしていくのが有効です。
この記事の執筆・編集担当
DE
松本 孝太郎
DELOGs編集部/中年新米プログラマー
ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。
▼ 関連記事
[管理画面フォーマット開発編 #5] ユーザプロフィール更新
プロフィール編集機能を拡張し「アバター削除」「メールアドレス変更新(メールでの本人認証+管理者承認)」「パスワード変更」を実装
2025/9/21公開
![[管理画面フォーマット開発編 #5] ユーザプロフィール更新のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-profile%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示
ユーザープロフィールに欠かせないアバター画像を、安全にアップロード・表示する仕組みを構築
2025/9/16公開
![[管理画面フォーマット開発編 #4] Server Actionで実装するアバター画像のアップロードと表示のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-avatar-upload%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能
httpOnly Cookie と middleware を組み合わせ、JWTはjtiのみを運ぶ“鍵”として使用。法人ユースに耐える堅牢なログインを実装
2025/9/12公開
![[管理画面フォーマット開発編 #2] JWT +Cookie+middlewareで実装するログイン機能のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-login%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有
ログイン成功直後に取得したユーザー情報をAuthProvider(Client Context)でアプリ全体に配布
2025/9/12公開
![[管理画面フォーマット開発編 #3] AuthProviderでログイン済みユーザー情報を全体共有のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-auth-provider%2Fhero-thumbnail.jpg&w=1200&q=75)
[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計
管理画面フォーマット(UIのみ版)を土台に、バックエンドの第一弾としてのDB設計
2025/9/10公開
![[管理画面フォーマット開発編 #1] Prisma × PostgreSQLで進めるDB設計のイメージ](/_next/image?url=%2Farticles%2Fnext-js%2Fformat-prisma-db-design%2Fhero-thumbnail.jpg&w=1200&q=75)