言語を切り替える
テーマを切り替える

Next.js 管理画面実践:RBAC 権限システムを設計から実装まで完全ガイド

エディタに表示された 23 個目の if (user.role === 'admin') を見つめています。

昨年引き継いだ管理画面プロジェクトでは、前任者が残した権限制御コードに苦しめられました。UserRole の判定が 20 以上のファイルに散在し、新しいロールを追加するたびに全体を検索して修正する必要がありました。あるとき修正漏れがあり、一般ユーザーが財務レポートを見られてしまいました。深夜に電話で呼び出され、バグ修正に追われたこともあります。

その頃、Next.js 管理画面の実装方法を徹底的に調べましたが、権限システムで悩んでいる開発者は多いことがわかりました。RBAC を使うべきだとは分かっていても、データベースはどう設計するか、ミドルウェアはどう書くか、動的メニューはどう生成するか、テーブルは Ant Design と shadcn/ui のどちらを選ぶか——標準的な答えはなく、試行錯誤は避けられません。

2 週間かけて権限システムをリファクタリングしたあと、ようやく安心して眠れるようになりました。本記事では、その経験を整理します。RBAC アーキテクチャ設計から Next.js 15 ミドルウェア実装動的メニュー生成テーブルコンポーネント選定まで、一式の方案をまとめています。

RBAC 権限モデル設計(なぜこの形なのか)

RBAC とは、なぜ広く使われるのか

RBAC は Role-Based Access Control(ロールベースアクセス制御)の略です。核となる考え方はとてもシンプル:ユーザー → ロール → 権限 → リソース

「ユーザーに直接権限を付与すればいいのでは?」と思うかもしれません。可能ですが、面倒です。

想像してください。会社に CS が 5 人入社したとします。直接権限方式なら、注文閲覧・コメント返信・レポート出力……を一人ひとり設定する必要があります。RBAC なら「CS」ロールを作り、権限をロールに紐付け、新人にはロールを割り当てるだけ。設定は 1 回、以降は使い回せます。

さらに重要なのはメンテナンスコストです。「CS はレポート出力禁止にする」と PM が言ったら、RBAC ならロール権限を 1 回変更するだけで全 CS に反映されます。直接付与なら全員分を個別修正。1 件漏れれば本番事故です。

80%+
エンタープライズ SaaS が RBAC を採用
柔軟性と保守性のバランスが取れており、ABAC よりシンプルで、直接ユーザー-権限バインドより柔軟

海外のエンタープライズ SaaS の 80% 以上が RBAC またはその派生を使っています。理由は実用的:柔軟性と保守性のバランスが取れているからです。ABAC(属性ベースアクセス制御)より単純で、直接ユーザー-権限バインドより柔軟です。

権限粒度をどう設計すれば疲れないか

権限粒度は難所です。粗すぎれば制御不足、細かすぎればメンテナンス地獄。

私の経験では 3 層に分けます:

ページ権限(ルーティングレベル)

  • 最も基本。特定ページへアクセスできるか
  • 例:/admin/users は管理者のみ
  • Next.js ミドルウェアで実装(後述)

モジュール権限(メニューレベル)

  • サイドバーに何を表示するか
  • 権限のないメニューを非表示にし、UX を向上
  • フロントで権限に応じてメニュー設定を動的フィルタ

操作権限(ボタンレベル)

  • 削除・編集など具体的な操作ボタン
  • 例:「ユーザー削除」はスーパー管理者のみ
  • 乱用注意。すべてのボタンに権限は不要

テーブルの列ごとに権限を付ける例も見ました。設定が複雑すぎ、性能も悪化。過剰設計しない——これが原則です。

権限命名は resource:action 形式を推奨:

  • user:create - ユーザー作成
  • order:delete - 注文削除
  • report:export - レポート出力

意味が一目でわかり、ソート・検索もしやすいです。

データベーステーブル設計

中核は 4 テーブル:ユーザー、ロール、権限、リソース。多対多は 2 つの関連テーブルで処理します。

// ユーザーテーブル
User {
  id: string
  name: string
  email: string
  // その他ユーザー情報
}

// ロールテーブル
Role {
  id: string
  name: string  // "管理者"、"CS"、"運用"
  code: string  // "admin"、"service"、"operator"
  description: string
}

// 権限テーブル
Permission {
  id: string
  name: string  // "ユーザー作成"
  code: string  // "user:create"
  resource: string  // "user"
  action: string  // "create"
}

// リソーステーブル(任意、業務複雑度次第)
Resource {
  id: string
  name: string  // "ユーザー管理"
  code: string  // "user"
  type: string  // "page" | "api" | "menu"
}

// ユーザー-ロール関連テーブル
UserRole {
  userId: string
  roleId: string
}

// ロール-権限関連テーブル
RolePermission {
  roleId: string
  permissionId: string
}

「User テーブルに roleId を直接持たせれば?」——答えは No1 ユーザーが複数ロールを持つからです。

例:田中さんが「技術責任者」かつ「コンテンツ審査員」。両ロールの権限をマージする必要があります。中間テーブルなら自然に表現でき、クエリも JOIN するだけです。

組織構造(部門・ポジション)が必要なら Department / Position テーブルを追加。最初から全部作らず、必要に応じて拡張が正解。Prisma などの ORM なら後からテーブル・カラム追加も容易です。

Next.js ミドルウェアによるルート保護(実装の核心)

なぜミドルウェアが必須か

最初は各ページコンポーネント内で権限判定を書いていました:

// ❌ 反面教師
export default function UsersPage() {
  const { user } = useSession()

  if (!user) {
    redirect('/login')
  }

  if (user.role !== 'admin') {
    return <div>権限がありません</div>
  }

  return <div>ユーザーリスト...</div>
}

一見問題なさそうですが:

  1. 全ページにコピペが必要
  2. 書き漏れでセキュリティホール
  3. ページ描画後に判定するためチラつき
  4. SSR 時のロジックが複雑化

Next.js ミドルウェアがこれを解決します。リクエストがページに到達する前に実行され、統一インターセプト・統一処理。性能がよく、コードも整理され、保守コストが下がります。

60-80%
レスポンス速度向上
ミドルウェア権限検証はコンポーネント内判定より 60〜80% 高速。不要なコンポーネント描画を削減

middleware.ts 完全実装

Next.js 15 のミドルウェアはプロジェクトルートの middleware.ts に書きます。ここでは NextAuth を使いますが、Clerk などに置き換え可能です。

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'

// ルート権限マップ
const ROUTE_PERMISSIONS = {
  '/admin': ['admin'],  // admin のみ
  '/admin/users': ['admin', 'operator'],  // admin と operator
  '/dashboard': ['admin', 'operator', 'viewer'],  // 3 ロールすべて
  '/reports': ['admin'],
} as const

// 公開ルート(ログイン不要)
const PUBLIC_ROUTES = ['/login', '/register', '/forgot-password']

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. 公開ルートはそのまま通す
  if (PUBLIC_ROUTES.includes(pathname)) {
    return NextResponse.next()
  }

  // 2. ユーザーセッション取得
  const token = await getToken({
    req: request,
    secret: process.env.NEXTAUTH_SECRET,
  })

  // 3. 未ログインならログインページへ
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)  // ログイン後の戻り先
    return NextResponse.redirect(loginUrl)
  }

  // 4. ルート権限チェック
  const userRole = token.role as string
  const requiredRoles = ROUTE_PERMISSIONS[pathname as keyof typeof ROUTE_PERMISSIONS]

  if (requiredRoles && !requiredRoles.includes(userRole)) {
    // 権限不足:403
    return NextResponse.rewrite(new URL('/403', request.url))
  }

  // 5. 通過
  return NextResponse.next()
}

// ミドルウェアマッチ設定
export const config = {
  matcher: [
    // 静的ファイルと API 以外(必要に応じ調整)
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

ポイント:

ルート権限マップ:定数オブジェクトで一覧化。新ルート追加もここだけ。

公開ルートホワイトリスト:ログイン・登録ページなどを別列挙。無限リダイレクトを防ぐ。

ログイン元の記録loginUrl.searchParams.set('from', pathname) が重要。/admin/users で弾かれたユーザーは、ログイン後に同ページへ戻す UX 配慮。

権限不足の処理redirect ではなく NextResponse.rewrite。URL は変えず 403 ページを表示。専用の無権限ページへリダイレクトする方法もあります。

フロントとバックの権限検証の連携

重要:ミドルウェアは第一防衛線。バックエンド API でも必ず再検証

フロント権限判定の本質は UX 最適化。ブラウザのコードは改変可能。DevTools を開けば権限チェックは迂回できます。本当の防衛線はサーバー側です。

Next.js の Server Actions と API ルートでも権限を再検証します:

// app/actions/deleteUser.ts
'use server'

import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deleteUser(userId: string) {
  // 権限を再検証
  const session = await auth()

  if (!session || session.user.role !== 'admin') {
    throw new Error('この操作を実行する権限がありません')
  }

  // 削除実行
  await db.user.delete({ where: { id: userId } })

  return { success: true }
}

二重防御:

  • フロントミドルウェア:素早いフィードバック。無権限ページを見せない
  • バックエンド検証:本当のセキュリティ防衛。悪意あるリクエストを防ぐ

権限設定を共有モジュールに切り出し、フロント・バックで同じ設定を参照するチームも多いです。monorepo なら特に便利です。

性能最適化:権限情報をどこに置くか

毎リクエスト DB で権限取得?遅すぎます。

2 つの方案:

方案 1:権限情報を JWT にエンコード

// NextAuth callbacks
callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.role = user.role
      token.permissions = user.permissions  // 権限リストも載せる
    }
    return token
  }
}

メリット:ミドルウェアで DB 参照不要。デメリット:権限変更はトークン失効まで反映されない。権限変更が少ないシーン向け。

方案 2:Redis でユーザー権限をキャッシュ

権限変更が頻繁なら Redis にキャッシュし、ミドルウェアで参照。高速でリアルタイム性も高いが、依存が 1 層増えます。

私のプロジェクトは方案 1。トークン有効期限 1 時間。管理者が権限を変えたら再ログインを促せば十分。権限調整は高頻度ではありません。

動的メニュー生成と権限連動(UX の要)

メニュー設定データ構造

動的メニューの核心は ユーザー権限でメニュー項目をフィルタすること。まず完全なメニュー設定を持ち、現在ユーザーの権限で動的に絞り込みます。

メニュー設定の例:

// config/menu.ts
import { Home, Users, Settings, FileText } from 'lucide-react'

export interface MenuItem {
  key: string
  label: string
  icon: React.ComponentType
  path?: string
  permission?: string  // 必要な権限
  children?: MenuItem[]
}

export const MENU_CONFIG: MenuItem[] = [
  {
    key: 'dashboard',
    label: 'ダッシュボード',
    icon: Home,
    path: '/dashboard',
    // permission 未設定 = 全ログインユーザー可
  },
  {
    key: 'users',
    label: 'ユーザー管理',
    icon: Users,
    permission: 'user:read',
    children: [
      {
        key: 'users-list',
        label: 'ユーザーリスト',
        path: '/admin/users',
        permission: 'user:read',
      },
      {
        key: 'users-roles',
        label: 'ロール管理',
        path: '/admin/roles',
        permission: 'role:read',
      },
    ],
  },
  {
    key: 'reports',
    label: 'レポートセンター',
    icon: FileText,
    path: '/reports',
    permission: 'report:read',
  },
  {
    key: 'settings',
    label: 'システム設定',
    icon: Settings,
    path: '/settings',
    permission: 'system:config',
  },
]

要点:

フラット vs ツリー:ここではツリー。ネスト関係が明確で、描画時に再帰処理。parentKey でフラット化する方法もあり、各有利弊。

permission は任意:未設定なら全ログインユーザーが閲覧可。「ダッシュボード」など基本ページは通常制限しない。

アイコンは文字列ではなくコンポーネントlucide-react を直接 import。型安全で描画も簡単。

メニューフィルタアルゴリズム

設定ができたら核心:権限に応じてメニューをフィルタ

落とし穴:親に権限がなく子に権限がある場合は?

例:ユーザーに user:read はないが role:read がある。「ユーザー管理」親メニューは表示するか?

私の方針:子メニューが 1 つでも見えれば親も表示。権限のある子メニューにアクセスできるようにします。

// lib/menu.ts
export function filterMenuByPermissions(
  menuItems: MenuItem[],
  userPermissions: string[]
): MenuItem[] {
  return menuItems
    .map((item) => {
      // 子メニュー処理
      const filteredChildren = item.children
        ? filterMenuByPermissions(item.children, userPermissions)
        : undefined

      // 現在項目の可視性
      const hasPermission =
        !item.permission || userPermissions.includes(item.permission)

      const hasVisibleChildren =
        filteredChildren && filteredChildren.length > 0

      // 権限も子もなければ除外
      if (!hasPermission && !hasVisibleChildren) {
        return null
      }

      return {
        ...item,
        children: filteredChildren,
      }
    })
    .filter((item): item is MenuItem => item !== null)
}

再帰フィルタでロジックが明確。メニュー項目は多くても数十件、性能も問題なし。

コンポーネントでの利用

メニューフィルタを React Hook に封装して再利用:

// hooks/usePermissionMenu.ts
'use client'

import { useMemo } from 'react'
import { useSession } from 'next-auth/react'
import { filterMenuByPermissions } from '@/lib/menu'
import { MENU_CONFIG } from '@/config/menu'

export function usePermissionMenu() {
  const { data: session } = useSession()

  const filteredMenu = useMemo(() => {
    if (!session?.user?.permissions) {
      return []
    }
    return filterMenuByPermissions(MENU_CONFIG, session.user.permissions)
  }, [session?.user?.permissions])

  return filteredMenu
}

useMemo でキャッシュ。権限リストが変わらなければ再フィルタしません。

サイドバーでの利用:

// components/Sidebar.tsx
'use client'

import { usePermissionMenu } from '@/hooks/usePermissionMenu'

export function Sidebar() {
  const menu = usePermissionMenu()

  return (
    <nav>
      {menu.map((item) => (
        <MenuItem key={item.key} item={item} />
      ))}
    </nav>
  )
}

すっきりした構成です。

ルートハイライトとパンくず

メニューフィルタに加え、現在ルートのハイライトパンくずの 2 点。

ルートハイライトは pathname マッチ:

'use client'

import { usePathname } from 'next/navigation'

function MenuItem({ item }: { item: MenuItem }) {
  const pathname = usePathname()
  const isActive = item.path === pathname

  return (
    <Link
      href={item.path || '#'}
      className={isActive ? 'bg-blue-100 text-blue-600' : 'text-gray-700'}
    >
      <item.icon />
      {item.label}
    </Link>
  )
}

パンくずは現在ルートに対応するメニューパスを探します:

// lib/menu.ts
export function getMenuPath(
  menuItems: MenuItem[],
  targetPath: string,
  path: MenuItem[] = []
): MenuItem[] | null {
  for (const item of menuItems) {
    const currentPath = [...path, item]

    if (item.path === targetPath) {
      return currentPath
    }

    if (item.children) {
      const result = getMenuPath(item.children, targetPath, currentPath)
      if (result) return result
    }
  }

  return null
}

再帰探索でルートから現在ノードまでのパスを返します。パンくずコンポーネントがそのまま使えます。

動的ルート(例:/admin/users/123)は動的パラメータ部分を除いてマッチ。業務に応じて調整してください。

データテーブルコンポーネント選定と実践

2026 年主流テーブル方案の比較

管理画面にテーブルは欠かせません。ユーザーリスト、注文リスト、ログリスト……どこにでもあります。適切なライブラリ選びで工数を大きく削れます。

主流方案を試した感触:

Ant Design Table

  • メリット:機能充実、ドキュメント充実、中国語対応。ソート・フィルタ・ページネーション・展開行・固定列すべて揃う
  • デメリット:スタイルカスタムが面倒、bundle サイズが大きい(antd 全体)、デザイン固定
  • 向き:従来型管理画面、Ant Design に慣れたチーム

MUI DataGrid

  • メリット:Material Design、機能強力、エンタープライズ機能(仮想スクロール、列並べ替え)
  • デメリット:高度機能は有料(Pro)、学習曲線急、スタイル上書き複雑
  • 向き:予算あり、大規模エンタープライズ機能が必要なプロジェクト

shadcn/ui + TanStack Table

  • メリット:スタイル制約少、高カスタマイズ、TypeScript フレンドリー、性能優秀。コンポーネントを自分で制御、必要に応じて import
  • デメリット:スタイルと UI を自分で書く初期投入
  • 向き:モダンプロジェクト、柔軟性と性能重視、コードを書く意欲のあるチーム

React-Admin

  • メリット:一体型、CRUD と権限統合、すぐ使える
  • デメリット:フレームワーク縛り、柔軟性低、カスタム制限
  • 向き:迅速プロトタイプ、標準 CRUD アプリ
300%+
shadcn/ui 成長率
2024〜2026 年、shadcn/ui + TanStack Table 組み合わせが 300% 超成長。モダン管理画面の第一選択に

最終的に shadcn/ui + TanStack Table を選びました。Tailwind CSS プロジェクトで shadcn/ui とシームレス、スタイル完全制御。TanStack Table の API 設計も優秀で、ロジックと UI 分離。UI ライブラリを変えてもロジックはそのまま。

shadcn/ui テーブル実装詳解

shadcn/ui の Data Table は完成コンポーネントではなく、組み立て方を教える方式。中核は TanStack Table、shadcn/ui が基本 Table UI を提供。

依存関係インストール:

npx shadcn@latest add table
npm install @tanstack/react-table

DataTable コンポーネントを作成(完全コードは記事先頭の例を参照)。

列定義だけで利用:

// app/admin/users/page.tsx
'use client'

import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/ui/button'
import { usePermission } from '@/hooks/usePermission'

interface User {
  id: string
  name: string
  email: string
  role: string
}

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: '氏名',
  },
  {
    accessorKey: 'email',
    header: 'メール',
  },
  {
    accessorKey: 'role',
    header: 'ロール',
  },
  {
    id: 'actions',
    cell: ({ row }) => {
      const user = row.original
      const { hasPermission } = usePermission()

      return (
        <div className="flex gap-2">
          {hasPermission('user:update') && (
            <Button size="sm" variant="outline">
              編集
            </Button>
          )}
          {hasPermission('user:delete') && (
            <Button size="sm" variant="destructive">
              削除
            </Button>
          )}
        </div>
      )
    },
  },
]

export default function UsersPage() {
  const users: User[] = [
    { id: '1', name: '田中', email: '[email protected]', role: 'admin' },
    { id: '2', name: '佐藤', email: '[email protected]', role: 'user' },
  ]

  return (
    <div className="container mx-auto py-10">
      <DataTable columns={columns} data={users} />
    </div>
  )
}

actions 列で usePermission Hook によりボタン表示を制御。権限の異なるユーザーは異なる操作ボタンを見ます。

サーバーサイドページネーションとフィルタ

前述はクライアントページネーション。データ量が増えると破綻します。

本番はサーバーサイドページネーションが一般的。バックエンド API の例:

// app/api/users/route.ts
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '0')
  const size = parseInt(searchParams.get('size') || '10')

  const [data, total] = await Promise.all([
    db.user.findMany({
      skip: page * size,
      take: size,
    }),
    db.user.count(),
  ])

  return Response.json({ data, total })
}

権限検証を忘れずに。ミドルウェアの章で説明した通りです。

テーブル権限制御のベストプラクティス

テーブル内権限制御は 2 層:

列権限:特定ロールのみ閲覧可の列(電話番号、身分証番号など)

const columns: ColumnDef<User>[] = [
  {
    accessorKey: 'name',
    header: '氏名',
  },
  // admin のみ機密列
  ...(hasPermission('user:view-sensitive')
    ? [
        {
          accessorKey: 'phone',
          header: '電話番号',
        },
      ]
    : []),
]

操作権限:操作列ボタンを権限で表示

前述の例の通り、usePermission Hook でボタン描画を制御。

汎用権限判定 Hook を封装:

// hooks/usePermission.ts
'use client'

import { useSession } from 'next-auth/react'

export function usePermission() {
  const { data: session } = useSession()

  const hasPermission = (permission: string) => {
    return session?.user?.permissions?.includes(permission) ?? false
  }

  const hasAnyPermission = (permissions: string[]) => {
    return permissions.some((p) => hasPermission(p))
  }

  const hasAllPermissions = (permissions: string[]) => {
    return permissions.every((p) => hasPermission(p))
  }

  return { hasPermission, hasAnyPermission, hasAllPermissions }
}

コンポーネントから使いやすく、ロジックも統一されます。

本番環境の注意点とベストプラクティス

よくある誤りとアンチパターン

踏んだ落とし穴をまとめます:

❌ 誤り 1:フロントだけで権限判定

最も危険。フロントコードはブラウザで実行、DevTools で自由に改変。

競合分析担当が一般ユーザーとして登録し、role: 'user'role: 'admin' に書き換え、一晩中バックエンドデータを閲覧した例も。翌日 PM の顔色が変わりました。

正解:フロント権限は UX 最適化。バックエンド API で必ず再検証。機密操作は Server Actions または API ルートで権限チェック。

❌ 誤り 2:権限判定コードが everywhere

if (user.role === 'admin') が 20 ファイルに散在。新ロール追加で地獄。

正解:統一権限設定 + 統一判定関数。前述の usePermission Hook がその考え方。

❌ 誤り 3:権限設定のハードコード

// 反面教師
const ADMIN_USERS = ['[email protected]', '[email protected]']
if (ADMIN_USERS.includes(user.email)) {
  // 管理者ロジック
}

上司のメール変更でコード修正・再デプロイが必要。

正解:権限設定は DB に保存、動的クエリ。ロールと権限の関係も設定で管理、コードに書かない。

性能最適化戦略

権限システムを誤ると性能劣化。いくつかの技巧:

1. 権限情報を Token にエンコード

前述の通り、ユーザーロールと権限リストを JWT に載せ、毎リクエスト DB 参照を避ける。

2. メニューフィルタ結果のキャッシュ

メニューフィルタは再帰操作。毎描画計算しない。

usePermissionMenu Hook の useMemo がキャッシュ。権限リスト不変なら再フィルタなし。

3. ルートレベルコード分割

Next.js App Router はルートレベルコード分割を標準サポート。各ページ独立 chunk、アクセス時のみロード。

管理画面ページ数が多いと、分割なしでは初回ロードが遅くなります。

4. 不要な権限判定を減らす

細かすぎる権限判定もあります。読み取り専用ページにアクセスできれば基本権限あり、ページ内ボタンは増分権限(削除・編集)だけ判定すれば十分な場合も。

セキュリティチェックリスト

リリース前に確認:

✅ バックエンド API で権限検証

  • すべての Server Actions に権限チェック
  • すべての API ルートに権限チェック
  • 機密操作に二次検証(ユーザー削除など)

✅ 権限昇格攻撃(Privilege Escalation)防止

  • ユーザーは自分のロールを変更不可
  • ユーザーは自分に権限追加不可
  • 低権限ユーザーは高権限リソースにアクセス不可

✅ 監査ログ

  • 重要操作を記録(ユーザー作成、データ削除、権限変更)
  • 操作者・操作時刻・操作内容を含む
  • ログ改ざん不可(追記のみ)

✅ セッション管理

  • Token 適切な有効期限(1 時間推奨)
  • 強制ログアウト(全セッション削除)対応
  • パスワード変更後は旧 Token 無効

✅ 入力検証

  • フロント・バック両方で入力検証
  • Zod などでデータ構造定義
  • SQL インジェクション防止(Prisma など ORM で自然に防御)

監視とアラート

権限システムのリリースは終わりではなく始まり。異常を早期発見:

監視指標

  • 403 エラー急増 → システム探索の可能性
  • 特定ユーザーの短時間大量リクエスト → クローラーまたは攻撃
  • 権限変更の頻発 → 設定の乱改

アラート戦略

  • スーパー管理者操作のリアルタイム通知
  • 権限設定変更のメール通知
  • 異常ログイン(异地・異常時間)の SMS 通知

Sentry、DataDog などで実装可能。

実際の教訓

オンライン障害事例——痛い教訓:

事例 1:メニュー権限とルート権限の不一致

メニュー設定で「財務レポート」を運用ロールに付与したが、ミドルウェアのルート権限に追加し忘れ。運用はメニューから入口を見えるが、クリックすると 403。1 週間ユーザーからの報告後に発覚。

教訓:権限設定は統一管理。メニュー権限とルート権限は同じ設定を使う。

事例 2:権限キャッシュによる反映遅延

権限を JWT にエンコード、有効期限 24 時間。運用がユーザー権限を取り消しても、翌日 Token 失効までアクセス継続。悪用が完了してから気づく。

教訓:機密操作は Token だけに依存せず、バックエンドで DB 再確認。または Redis で「取り消し権限」ブラックリスト。

事例 3:API 権限検証の漏れ

フロントページの権限制御は完璧だが、1 つの API に権限検証なし。Postman で直接呼び出し、フロント防御をすべて迂回。

教訓:バックエンド API が最後の防衛線。ミドルウェアまたはデコレータで統一処理。開発者が各 API に個別追加することを期待しない。

核心は一言:フロント権限は UX、バックエンド権限はセキュリティ。両方必要だが、バックエンドの方が重要

結論

振り返ると、権限システムは高深な技術ではないが、きちんと作るのは容易ではありません。

本記事は RBAC 設計から Next.js 15 ミドルウェア実装、動的メニュー、テーブルコンポーネントまで、管理画面権限システムの全体をカバーしました。核心は 3 点:

  1. 設計で過剰にしない:必要に応じて拡張。最初から複雑すぎるモデルを作らない
  2. 実装は階層化:ミドルウェアでルート、メニューで権限フィルタ、ボタンは必要に応じて表示。役割分担
  3. セキュリティは二重防御:フロントで UX、バックエンドで安全。両方必要

管理画面開発中なら、こう始めることをおすすめします:

  • まず RBAC 4 テーブル(ユーザー、ロール、権限、リソース)を構築
  • Next.js ミドルウェアでルート保護、権限設定を定数に切り出し
  • 動的メニューフィルタを実装し、Hook に封装
  • テーブルは shadcn/ui + TanStack Table。柔軟性最高

2 週間のリファクタリングは大変でしたが、価値あり。今は新ロール追加が DB 設定だけ。PM が「監査員」ロール追加を依頼しても 10 分で完了。

権限システムが整えば、チーム全体の開発効率が上がります。安全事故のあとで慌てないうちに、今から整えましょう。

記事で触れたオープンソース HaloLight は参考になります。Next.js 15 + React 19 + TypeScript + RBAC の完全実装。コード品質も高く、学習価値大。

実装中に問題があれば、コメントで議論してください。権限システムの落とし穴はだいたい踏みました。できる限りお役に立てれば幸いです。

Next.js 管理画面 RBAC 権限システム実装フロー

Next.js 管理画面 RBAC 権限システムをゼロから構築する完全手順

⏱️ 目安時間: 120 分

  1. 1

    ステップ1: ステップ 1:RBAC データベーステーブル設計

    4 つのコアテーブルと 2 つの関連テーブルを作成:

    **コアテーブル**:
    • User テーブル:ユーザー基本情報
    • Role テーブル:ロール定義(admin、operator、viewer など)
    • Permission テーブル:権限定義(resource:action 形式、例 user:create)
    • Resource テーブル(任意):リソース定義

    **関連テーブル**:
    • UserRole:ユーザー-ロール多対多
    • RolePermission:ロール-権限多対多

    **命名規則**:
    権限は resource:action 形式。管理・検索が容易。

    **拡張性**:
    初期はシンプルに。後から Department(部門)と Position(ポジション)を追加して組織構造に対応。

    Prisma など ORM で DB 構造を管理し、後から調整しやすくする。
  2. 2

    ステップ2: ステップ 2:Next.js ミドルウェアルート保護

    プロジェクトルートに middleware.ts を作成:

    **ルート権限マップ設定**:
    • ROUTE_PERMISSIONS 定数オブジェクトを作成
    • 各ルートに必要なロールリストを定義
    • PUBLIC_ROUTES ホワイトリスト(ログイン・登録ページなど)

    **ミドルウェア核心ロジック**:
    1. 公開ルートか確認、公開なら通す
    2. getToken でユーザーセッション取得
    3. 未ログインはログインページへ(元ページを記録)
    4. ユーザーロールがルート要件と一致するか確認
    5. 権限不足なら 403 ページ

    **性能最適化**:
    • ユーザー権限を JWT にエンコード
    • 毎リクエスト DB 参照を避ける
    • Token 有効期限を適切に(1 時間推奨)

    **matcher 設定**:
    静的ファイルと API を除外し、ページルートのみ検証。
  3. 3

    ステップ3: ステップ 3:動的メニュー生成と権限フィルタ

    メニュー設定とフィルタロジックを作成:

    **メニュー設定構造**(config/menu.ts):
    • ツリー構造でメニュー定義
    • 各項目に key、label、icon、path、permission
    • permission は任意。未設定なら全ログインユーザー可

    **メニューフィルタアルゴリズム**(lib/menu.ts):
    • filterMenuByPermissions 再帰関数を実装
    • 親子メニュー権限関係を処理(親に権限なく子にあれば親を表示)
    • フィルタ後のメニューツリーを返す

    **カスタム Hook 封装**(hooks/usePermissionMenu.ts):
    • useSession でユーザー権限取得
    • useMemo でフィルタ結果キャッシュ
    • 権限不変時は再計算回避

    **ルートハイライトとパンくず**:
    • usePathname で現在ルート取得
    • getMenuPath でパンくずパス生成
    • 動的ルートパラメータ対応
  4. 4

    ステップ4: ステップ 4:shadcn/ui + TanStack Table 統合

    再利用可能なデータテーブルコンポーネントを実装:

    **依存関係インストール**:
    • npx shadcn@latest add table
    • npm install @tanstack/react-table

    **DataTable コンポーネント作成**:
    • TanStack Table の useReactTable Hook を使用
    • ソート・ページネーション・フィルタ等の基本機能
    • TypeScript 型安全サポート

    **テーブル権限制御**:
    • 列レベル:条件レンダリングで機密列表示制御
    • 操作レベル:usePermission Hook でボタン表示制御
    • hasPermission、hasAnyPermission、hasAllPermissions 等

    **サーバーサイドページネーション**:
    • API ルートで page と size パラメータ受信
    • Prisma の skip と take でページネーション
    • データリストと総数を返却

    **権限検証**:
    フロントテーブル権限は UX 最適化。バックエンド API で必ず再検証。
  5. 5

    ステップ5: ステップ 5:バックエンド API と Server Actions 権限検証

    バックエンドセキュリティ防衛線を確保:

    **Server Actions 権限検証**:
    • 各 Server Action 先頭で auth() 呼び出し
    • ユーザーロールと権限を確認
    • 権限不足ならエラーを throw

    **API ルート権限検証**:
    • getToken でユーザー情報取得
    • リクエスト正当性を検証
    • 機密操作に二次検証追加

    **フロント・バック権限設定統一**:
    • 権限設定を共有モジュールに切り出し
    • フロント・バックで同じ設定を参照
    • monorepo 構成で特に便利

    **監査ログ記録**:
    • 重要操作(作成、削除、権限変更)を記録
    • 操作者・時刻・内容を含む
    • ログは追記のみ、改ざん不可
  6. 6

    ステップ6: ステップ 6:性能最適化とセキュリティ強化

    本番環境最適化:

    **性能最適化**:
    • JWT にユーザー権限情報をエンコード
    • useMemo でメニューフィルタ結果キャッシュ
    • ルートレベルコード分割で初回ロード削減
    • 不要な重複権限判定を削減

    **セキュリティチェックリスト**:
    • すべてのバックエンド API に権限検証
    • 権限昇格攻撃防止
    • Token 適切な有効期限設定
    • フロント・バック両方で入力検証
    • Zod でデータ構造定義

    **監視とアラート**:
    • 403 エラー数を監視
    • ユーザー異常リクエストを監視
    • スーパー管理者操作のリアルタイム通知
    • 権限設定変更のメール通知

    **よくある落とし穴の回避**:
    • フロントだけで権限判定しない
    • 権限設定をハードコードしない
    • メニュー権限とルート権限を一致させる
    • 機密操作を Token キャッシュだけに依存しない

FAQ

なぜ RBAC を使い、ユーザーに直接権限を付与しないのか?
RBAC の核心メリットはメンテナンスコスト:

• **一括管理**:CS 5 人増員でもロール割り当てだけ。5 回個別設定不要
• **一括更新**:ロール権限変更で該当ユーザー全員に即反映
• **拡張性**:1 ユーザーが複数ロール可。権限は自動マージ
• **ミス低減**:直接ユーザー-権限バインドは修正漏れでセキュリティリスク

海外 80% 超のエンタープライズ SaaS が RBAC を採用。柔軟性と保守性のバランスが最適。
Next.js ミドルウェアとコンポーネント内権限判定の違いは?
役割が異なります:

**ミドルウェア(推奨)**:
• ページ到達前に実行、統一インターセプト
• 性能良好。コンポーネント内より 60〜80% 高速
• コード集中管理、漏れにくい
• SSR 権限検証にも対応

**コンポーネント内判定**:
• ページ描画後に判定、チラつきの可能性
• 各ページに記述、漏れやすい
• コピペでメンテナンスコスト高

ただし:ミドルウェアは第一防衛線。バックエンド API で必ず再検証!
親メニューに権限がなく子メニューに権限がある場合は?
推奨は「子メニュー優先」:

**表示ロジック**:
• 子メニューが 1 つでも見えれば親も表示
• ユーザーは権限のある子メニューにアクセス可能

**実装方式**:
再帰アルゴリズムで子を先にフィルタし、親の可視性を判定:
1. 子メニューを再帰処理
2. 現在項目の権限確認
3. 権限なくても可視子があれば残す
4. 権限も可視子もなければ除外

権限制御の厳密さと UX の両立。
shadcn/ui + TanStack Table と Ant Design Table はどう選ぶ?
プロジェクト特性で選択:

**Ant Design Table を選ぶ場合**:
• チームが Ant Design に慣れている
• 迅速開発、すぐ使える機能が必要
• 従来型エンタープライズ管理画面
• 大きな bundle サイズを気にしない

**shadcn/ui + TanStack Table を選ぶ場合**:
• Tailwind CSS 使用
• 高度なスタイルカスタムが必要
• 柔軟性と性能重視
• コードを書く時間に投資できる

**データ比較**:
shadcn/ui + TanStack Table は 2024〜2026 年に 300% 超成長。モダン管理画面の第一選択に。

どちらも優秀。プロジェクト要件とチーム技術スタック次第。
フロントとバックエンド権限検証はどう連携する?
二重防御、役割分担:

**フロント権限検証**(ミドルウェア + コンポーネント):
• 目的:UX 最適化、素早いフィードバック
• 位置:ミドルウェアでルート、コンポーネントでボタン
• 限界:DevTools で迂回可能。セキュリティ防衛線ではない

**バックエンド権限検証**(API + Server Actions):
• 目的:本当のセキュリティ防衛
• 位置:各 Server Action と API ルート
• 必須:機密操作は必ず検証。フロントに依存しない

**設定統一**:
• 権限設定を共有モジュールに
• フロント・バックで同じ設定参照
• ルール一致で抜け穴防止

**教訓**:フロント権限は完璧だが 1 API に検証漏れ。Postman で直接呼び出され全防御迂回。
権限情報は JWT に載せるか、毎回 DB を参照するか?
シーン別に選択:

**方案 1:JWT エンコード(推奨)**:
• メリット:ミドルウェアで DB 参照不要、性能良好
• デメリット:権限変更は Token 失効まで反映されない
• 向き:権限変更が少ないシーン
• 推奨:Token 有効期限 1 時間

**方案 2:Redis キャッシュ**:
• メリット:リアルタイム性高、権限即反映
• デメリット:依存増、複雑度上昇
• 向き:権限変更が頻繁なシーン

**方案 3:DB クエリ**:
• メリット:100% リアルタイム
• デメリット:毎リクエスト DB、性能悪
• 非推奨:特殊業務要件以外

**ハイブリッド**:
JWT に権限 + Redis ブラックリスト(取り消し権限)。性能とリアルタイム性の両立。
権限システムリリース前に確認すべきセキュリティ項目は?
完全なセキュリティチェックリスト:

**バックエンド API 検証**:
• すべての Server Actions に権限チェック
• すべての API ルートに権限検証
• 機密操作に二次検証(ユーザー削除など)

**権限昇格攻撃防止**:
• ユーザーは自分のロール変更不可
• ユーザーは自分に権限追加不可
• 低権限ユーザーは高権限リソース不可

**監査と監視**:
• 重要操作ログ(改ざん不可)
• 403 エラー数監視
• スーパー管理者操作リアルタイム通知
• 権限設定変更メール通知

**セッション管理**:
• Token 適切な有効期限(1 時間推奨)
• 強制ログアウト対応
• パスワード変更後旧 Token 無効

**入力検証**:
• フロント・バック両方で検証
• Zod でデータ構造定義
• SQL インジェクション防止(Prisma 等 ORM)

覚えておくこと:フロント権限は UX、バックエンド権限はセキュリティ!

8分で読めます · 公開日: 2026年1月7日 · 更新日: 2026年6月8日

関連記事

コメント

GitHubアカウントでログインしてコメントできます