Skilore

フロントエンド認可

フロントエンドの認可はUX向上のための「表示制御」であり、セキュリティの最終防衛線ではない。ルートガード、コンポーネントの条件表示、権限に基づくUI制御、CASL/Zod を使った宣言的認可、Server Components との責務分担、権限のプリフェッチ戦略を網羅的に解説する。

90 分で読めます44,979 文字

フロントエンド認可

フロントエンドの認可はUX向上のための「表示制御」であり、セキュリティの最終防衛線ではない。ルートガード、コンポーネントの条件表示、権限に基づくUI制御、CASL/Zod を使った宣言的認可、Server Components との責務分担、権限のプリフェッチ戦略を網羅的に解説する。

この章で学ぶこと

  • フロントエンド認可の役割と限界を正確に理解する
  • Next.js Middleware と React Router のルートガードを実装できるようになる
  • 権限ベースの UI 制御を宣言的に実装する方法を学ぶ
  • Server Components と Client Components の認可責務分担を把握する
  • 権限のプリフェッチ、キャッシュ、リアルタイム更新を実践する

前提知識

  • React(Context API, Hooks)の基礎
  • Next.js App Router(Server Components, Middleware)の基礎

1. フロントエンド認可の原則

1.1 フロントエンドとバックエンドの責務分担

重要な原則:

  フロントエンドの認可 = UX の最適化
  サーバーの認可 = セキュリティの保証
認可の階層構造
┌──────────────────────────────────────────────┐
Layer 1: フロントエンド(表示制御)
→ 権限のないメニュー項目を非表示
→ 権限のないボタンを無効化(disabled)
→ 権限のないページへアクセス時にリダイレクト
→ ユーザーに不要な選択肢を見せない
→ 操作不可の理由をユーザーに説明
目的: ユーザー体験の向上
信頼度: 低(バイパス可能)
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
Layer 2: BFF / API Gateway
→ ルートレベルの認可チェック
→ トークン検証
→ レート制限
目的: 粗いアクセス制御
信頼度: 中
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
Layer 3: バックエンド(セキュリティ)
→ 全 API リクエストで権限チェック
→ リソースレベルの認可(所有権チェック)
→ フロントエンドの表示状態に依存しない
→ フロントエンドがバイパスされても安全
目的: セキュリティの保証
信頼度: 高(唯一の信頼境界)
└──────────────────────────────────────────────┘

1.2 なぜフロントエンドのみの認可は危険なのか

フロントエンドのみの認可が危険な理由:

  攻撃手法①: DevTools による操作
// DevTools Console で実行
document.querySelector('[disabled]')
.removeAttribute('disabled');
// → 無効化されたボタンがクリック可能に
// hidden 要素の表示
document.querySelector('.hidden')
.style.display = 'block';
// → 非表示だった管理者メニューが表示される
攻撃手法②: API の直接呼び出し
// フロントエンドを完全にスキップ
curl -X DELETE https://api.example.com/users/123
-H "Cookie: session=stolen_session"
// → フロントエンドの表示制御は意味なし
攻撃手法③: JavaScript の改変
// React DevTools でステートを書き換え
// user.role = 'viewer' → user.role = 'admin'
// → クライアント側の権限チェックがバイパスされる
正しいアプローチ:
✓ バックエンドで必ず認可チェック
✓ フロントエンドは UX のために補助的に表示制御
✓ フロントエンドの権限データはバックエンドから取得
✓ 操作の最終判断はバックエンドが行う

1.3 フロントエンド認可で実現すべきこと

フロントエンド認可の具体的な目的:

  ① プリベンティブ UI:
     → 権限のない操作を事前にブロック
     → ユーザーがエラーに遭遇する前に防止
     → 例: 編集権限がなければ Edit ボタンを非表示

  ② ガイダンス:
     → なぜ操作できないかを説明
     → 権限取得の方法を案内
     → 例: 「編集するにはエディターロールが必要です」

  ③ パフォーマンス:
     → 不要な API コールを防止
     → 権限のないデータの取得を避ける
     → 例: 管理者でなければ管理者 API を呼ばない

  ④ 情報の最小化:
     → 権限のないデータをそもそも取得しない
     → Server Components で権限に応じたデータのみ返す
     → 例: 一般ユーザーには管理者メニューの存在すら見せない

2. ルートガード

2.1 Next.js Middleware によるルートガード

// middleware.ts - Next.js Middleware によるルートガード
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
 
// 保護ルートの定義(パス → 許可ロール)
const protectedRoutes: Record<string, string[]> = {
  '/dashboard': ['viewer', 'editor', 'admin'],
  '/articles/new': ['editor', 'admin'],
  '/articles/edit': ['editor', 'admin'],
  '/admin': ['admin'],
  '/settings': ['editor', 'admin'],
  '/billing': ['admin'],
};
 
// 公開ルート(認証不要)
const publicRoutes = new Set([
  '/',
  '/login',
  '/register',
  '/forgot-password',
  '/reset-password',
  '/verify-email',
  '/about',
  '/pricing',
]);
 
export default auth((request) => {
  const { pathname } = request.nextUrl;
  const session = request.auth;
 
  // 静的ファイルと API ルートはスキップ
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api/') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }
 
  // 公開ルートはそのまま
  if (publicRoutes.has(pathname)) {
    // ログイン済みユーザーが /login にアクセスした場合はリダイレクト
    if (session && pathname === '/login') {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
    return NextResponse.next();
  }
 
  // 認証チェック
  if (!session) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }
 
  // 保護ルートのロールチェック
  const matchedRoute = Object.keys(protectedRoutes).find(
    (route) => pathname.startsWith(route)
  );
 
  if (matchedRoute) {
    const allowedRoles = protectedRoutes[matchedRoute];
    const userRole = session.user?.role;
 
    if (!userRole || !allowedRoles.includes(userRole)) {
      // 権限不足: 403 ページにリダイレクト
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    // 静的ファイルと内部パスを除外
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};
Middleware の動作フロー:

  リクエスト
  │
  ├─ 静的ファイル? ──Yes──→ スキップ(NextResponse.next())
  │
  ├─ 公開ルート? ──Yes──→ そのまま通過
  │   └─ ログイン済み + /login? ──→ /dashboard にリダイレクト
  │
  ├─ セッションあり? ──No──→ /login にリダイレクト(callbackUrl 付き)
  │
  ├─ 保護ルート? ──Yes──→ ロールチェック
  │   ├─ 許可ロール? ──Yes──→ NextResponse.next()
  │   └─ 不許可? ──→ /unauthorized にリダイレクト
  │
  └─ その他 ──→ NextResponse.next()

  注意事項:
・Middleware は Edge Runtime で動作
・DB アクセスは制限がある(Prisma は使用不可)
・JWT の検証のみで判断するのが一般的
・matcher で対象パスを適切に制限する
・パフォーマンスに影響するため処理は軽量に

2.2 動的ルートの権限チェック

// 動的ルートでのリソースレベル認可
// middleware だけでは不十分な場合 → ページコンポーネントで追加チェック
 
// app/articles/[id]/edit/page.tsx
import { auth } from '@/auth';
import { redirect, notFound } from 'next/navigation';
 
export default async function EditArticlePage({
  params,
}: {
  params: { id: string };
}) {
  const session = await auth();
  if (!session) redirect('/login');
 
  // 記事の取得
  const article = await prisma.article.findUnique({
    where: { id: params.id },
  });
 
  if (!article) notFound();
 
  // リソースレベルの認可チェック
  const canEdit =
    session.user.role === 'admin' ||
    article.authorId === session.user.id;
 
  if (!canEdit) {
    redirect('/unauthorized');
  }
 
  return <ArticleEditor article={article} />;
}

2.3 React Router でのルートガード

// React Router v6 でのルートガード(SPA 向け)
 
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
 
// 基本のルートガード
function ProtectedRoute({
  children,
  requiredRole,
  requiredPermission,
  fallback,
}: {
  children?: React.ReactNode;
  requiredRole?: string[];
  requiredPermission?: string;
  fallback?: React.ReactNode;
}) {
  const { user, isLoading, can } = useAuth();
  const location = useLocation();
 
  // ローディング中はスケルトンを表示
  if (isLoading) {
    return fallback ?? <FullPageSkeleton />;
  }
 
  // 未認証 → ログインページにリダイレクト
  if (!user) {
    return (
      <Navigate
        to="/login"
        state={{ from: location.pathname }}
        replace
      />
    );
  }
 
  // ロールチェック
  if (requiredRole && !requiredRole.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }
 
  // パーミッションチェック
  if (requiredPermission && !can(requiredPermission)) {
    return <Navigate to="/unauthorized" replace />;
  }
 
  return children ?? <Outlet />;
}
 
// 使用例: ルート定義
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
 
const router = createBrowserRouter([
  // 公開ルート
  { path: '/login', element: <LoginPage /> },
  { path: '/register', element: <RegisterPage /> },
 
  // 認証必須ルート
  {
    element: <ProtectedRoute />,
    children: [
      { path: '/dashboard', element: <DashboardPage /> },
      { path: '/profile', element: <ProfilePage /> },
    ],
  },
 
  // エディター以上
  {
    element: <ProtectedRoute requiredRole={['editor', 'admin']} />,
    children: [
      { path: '/articles/new', element: <NewArticlePage /> },
      { path: '/articles/:id/edit', element: <EditArticlePage /> },
    ],
  },
 
  // 管理者のみ
  {
    element: <ProtectedRoute requiredRole={['admin']} />,
    children: [
      { path: '/admin', element: <AdminDashboard /> },
      { path: '/admin/users', element: <UserManagementPage /> },
      { path: '/admin/settings', element: <AdminSettingsPage /> },
    ],
  },
 
  // パーミッションベース
  {
    element: <ProtectedRoute requiredPermission="billing:manage" />,
    children: [
      { path: '/billing', element: <BillingPage /> },
    ],
  },
 
  // 404 / 403
  { path: '/unauthorized', element: <UnauthorizedPage /> },
  { path: '*', element: <NotFoundPage /> },
]);

3. 権限ベースの UI 制御

3.1 AuthContext の設計

// lib/auth-context.tsx - 権限コンテキストの完全実装
'use client';
 
import {
  createContext,
  useContext,
  useCallback,
  useMemo,
  type ReactNode,
} from 'react';
import { useSession } from 'next-auth/react';
import { useQuery } from '@tanstack/react-query';
 
// 権限の型定義
type Permission = string; // "resource:action" 形式
 
interface AuthContextValue {
  // ユーザー情報
  user: {
    id: string;
    name: string;
    email: string;
    role: string;
    image?: string;
  } | null;
 
  // 認証状態
  isAuthenticated: boolean;
  isLoading: boolean;
 
  // 権限
  permissions: Set<Permission>;
  permissionsLoading: boolean;
 
  // 権限チェック関数
  can: (action: string, resource?: string) => boolean;
  canAny: (permissions: string[]) => boolean;
  canAll: (permissions: string[]) => boolean;
 
  // ロールチェック
  hasRole: (role: string) => boolean;
  hasAnyRole: (roles: string[]) => boolean;
}
 
const AuthContext = createContext<AuthContextValue>({
  user: null,
  isAuthenticated: false,
  isLoading: true,
  permissions: new Set(),
  permissionsLoading: true,
  can: () => false,
  canAny: () => false,
  canAll: () => false,
  hasRole: () => false,
  hasAnyRole: () => false,
});
 
// 権限のフェッチ
async function fetchPermissions(): Promise<Set<Permission>> {
  const res = await fetch('/api/auth/permissions');
  if (!res.ok) throw new Error('Failed to fetch permissions');
  const data = await res.json();
  return new Set<Permission>(data.permissions);
}
 
export function AuthProvider({ children }: { children: ReactNode }) {
  const { data: session, status } = useSession();
  const isAuthenticated = status === 'authenticated';
 
  // 権限のフェッチ(React Query でキャッシュ管理)
  const {
    data: permissions = new Set<Permission>(),
    isLoading: permissionsLoading,
  } = useQuery({
    queryKey: ['permissions', session?.user?.id],
    queryFn: fetchPermissions,
    enabled: isAuthenticated,
    staleTime: 5 * 60 * 1000,   // 5 分間はキャッシュを使用
    gcTime: 30 * 60 * 1000,     // 30 分間メモリに保持
    refetchOnWindowFocus: false, // タブ切替時の再取得を防止
    retry: 2,
  });
 
  // 権限チェック: resource:action 形式
  const can = useCallback(
    (action: string, resource?: string): boolean => {
      if (!isAuthenticated) return false;
 
      // admin ロールは全権限を持つ
      if (session?.user?.role === 'admin') return true;
 
      const permission = resource ? `${resource}:${action}` : action;
      return permissions.has(permission);
    },
    [isAuthenticated, session?.user?.role, permissions]
  );
 
  // いずれかの権限を持っているか
  const canAny = useCallback(
    (perms: string[]): boolean => {
      return perms.some((p) => {
        const [resource, action] = p.split(':');
        return can(action, resource);
      });
    },
    [can]
  );
 
  // 全ての権限を持っているか
  const canAll = useCallback(
    (perms: string[]): boolean => {
      return perms.every((p) => {
        const [resource, action] = p.split(':');
        return can(action, resource);
      });
    },
    [can]
  );
 
  // ロールチェック
  const hasRole = useCallback(
    (role: string): boolean => {
      return session?.user?.role === role;
    },
    [session?.user?.role]
  );
 
  const hasAnyRole = useCallback(
    (roles: string[]): boolean => {
      return roles.includes(session?.user?.role ?? '');
    },
    [session?.user?.role]
  );
 
  const value = useMemo<AuthContextValue>(
    () => ({
      user: session?.user
        ? {
            id: session.user.id!,
            name: session.user.name!,
            email: session.user.email!,
            role: session.user.role as string,
            image: session.user.image ?? undefined,
          }
        : null,
      isAuthenticated,
      isLoading: status === 'loading',
      permissions,
      permissionsLoading,
      can,
      canAny,
      canAll,
      hasRole,
      hasAnyRole,
    }),
    [session, isAuthenticated, status, permissions, permissionsLoading,
     can, canAny, canAll, hasRole, hasAnyRole]
  );
 
  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}
 
export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

3.2 権限 API エンドポイント

// app/api/auth/permissions/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
 
// ロールごとの権限マッピング
const rolePermissions: Record<string, string[]> = {
  viewer: [
    'articles:read',
    'comments:read',
    'comments:create',
    'profile:read',
    'profile:update',
  ],
  editor: [
    'articles:read',
    'articles:create',
    'articles:update',
    'articles:publish',
    'comments:read',
    'comments:create',
    'comments:update',
    'comments:delete',
    'media:upload',
    'profile:read',
    'profile:update',
  ],
  admin: [
    'admin', // 特殊権限: 全権限を包含
  ],
};
 
export async function GET() {
  const session = await auth();
 
  if (!session?.user) {
    return NextResponse.json({ permissions: [] }, { status: 401 });
  }
 
  const role = session.user.role as string;
  const permissions = rolePermissions[role] ?? [];
 
  // ユーザー固有の追加権限(DB から取得する場合)
  // const userPermissions = await prisma.userPermission.findMany({
  //   where: { userId: session.user.id },
  //   select: { permission: true },
  // });
  // const extraPermissions = userPermissions.map(p => p.permission);
 
  return NextResponse.json({
    permissions: [...permissions],
    role,
  });
}

3.3 Authorized コンポーネント(宣言的認可)

// components/authorized.tsx - 宣言的な権限チェックコンポーネント
'use client';
 
import { useAuth } from '@/lib/auth-context';
import type { ReactNode } from 'react';
 
interface AuthorizedProps {
  // 単一権限チェック
  permission?: string;
  // 複数権限(いずれか)
  anyPermission?: string[];
  // 複数権限(全て)
  allPermissions?: string[];
  // ロールチェック
  role?: string;
  anyRole?: string[];
  // 権限がない場合の表示
  fallback?: ReactNode;
  // 権限がない場合にdisabledで表示するか
  showDisabled?: boolean;
  // 子要素
  children: ReactNode;
}
 
export function Authorized({
  permission,
  anyPermission,
  allPermissions,
  role,
  anyRole,
  fallback = null,
  showDisabled = false,
  children,
}: AuthorizedProps) {
  const { can, canAny, canAll, hasRole, hasAnyRole, permissionsLoading } = useAuth();
 
  // 権限ロード中は何も表示しない(ちらつき防止)
  if (permissionsLoading) return null;
 
  let authorized = true;
 
  // 権限チェック
  if (permission) {
    const [resource, action] = permission.split(':');
    authorized = can(action, resource);
  }
 
  if (anyPermission) {
    authorized = canAny(anyPermission);
  }
 
  if (allPermissions) {
    authorized = canAll(allPermissions);
  }
 
  // ロールチェック
  if (role) {
    authorized = hasRole(role);
  }
 
  if (anyRole) {
    authorized = hasAnyRole(anyRole);
  }
 
  if (!authorized) {
    if (showDisabled) {
      // disabled 状態で表示
      return (
        <div className="opacity-50 pointer-events-none" aria-disabled="true">
          {children}
        </div>
      );
    }
    return <>{fallback}</>;
  }
 
  return <>{children}</>;
}
 
// 使用例
function ArticleCard({ article }: { article: Article }) {
  return (
    <div className="border rounded-lg p-4">
      <h2 className="text-xl font-bold">{article.title}</h2>
      <p className="text-gray-600 mt-2">{article.excerpt}</p>
 
      <div className="flex gap-2 mt-4">
        {/* 閲覧権限のある人だけに表示 */}
        <Authorized permission="articles:update">
          <Link href={`/articles/${article.id}/edit`} className="btn-secondary">
            編集
          </Link>
        </Authorized>
 
        {/* 削除権限: 無効化状態で表示 */}
        <Authorized
          permission="articles:delete"
          showDisabled
          fallback={
            <button disabled className="btn-danger opacity-50" title="削除権限がありません">
              削除
            </button>
          }
        >
          <button
            className="btn-danger"
            onClick={() => deleteArticle(article.id)}
          >
            削除
          </button>
        </Authorized>
 
        {/* 公開権限 */}
        <Authorized permission="articles:publish">
          {article.status === 'draft' && (
            <button
              className="btn-primary"
              onClick={() => publishArticle(article.id)}
            >
              公開
            </button>
          )}
        </Authorized>
 
        {/* 管理者のみ */}
        <Authorized role="admin">
          <button
            className="btn-outline"
            onClick={() => viewAuditLog(article.id)}
          >
            監査ログ
          </button>
        </Authorized>
      </div>
    </div>
  );
}

3.4 useAuthorized フック

// hooks/useAuthorized.ts - フックとしての権限チェック
'use client';
 
import { useAuth } from '@/lib/auth-context';
import { useMemo } from 'react';
 
interface UseAuthorizedOptions {
  permission?: string;
  anyPermission?: string[];
  allPermissions?: string[];
  role?: string;
  anyRole?: string[];
}
 
interface UseAuthorizedResult {
  authorized: boolean;
  loading: boolean;
}
 
export function useAuthorized(options: UseAuthorizedOptions): UseAuthorizedResult {
  const { can, canAny, canAll, hasRole, hasAnyRole, permissionsLoading } = useAuth();
 
  const authorized = useMemo(() => {
    if (permissionsLoading) return false;
 
    if (options.permission) {
      const [resource, action] = options.permission.split(':');
      if (!can(action, resource)) return false;
    }
 
    if (options.anyPermission) {
      if (!canAny(options.anyPermission)) return false;
    }
 
    if (options.allPermissions) {
      if (!canAll(options.allPermissions)) return false;
    }
 
    if (options.role) {
      if (!hasRole(options.role)) return false;
    }
 
    if (options.anyRole) {
      if (!hasAnyRole(options.anyRole)) return false;
    }
 
    return true;
  }, [options, can, canAny, canAll, hasRole, hasAnyRole, permissionsLoading]);
 
  return {
    authorized,
    loading: permissionsLoading,
  };
}
 
// 使用例
function ArticleActions({ article }: { article: Article }) {
  const { authorized: canEdit } = useAuthorized({ permission: 'articles:update' });
  const { authorized: canDelete } = useAuthorized({ permission: 'articles:delete' });
  const { authorized: isAdmin } = useAuthorized({ role: 'admin' });
 
  return (
    <div className="flex gap-2">
      {canEdit && <EditButton articleId={article.id} />}
      {canDelete && <DeleteButton articleId={article.id} />}
      {isAdmin && <AdminActions articleId={article.id} />}
    </div>
  );
}

4. ナビゲーションの権限制御

4.1 権限ベースのナビゲーション定義

// lib/navigation.ts - ナビゲーション項目の定義
import {
  HomeIcon,
  DocumentIcon,
  UsersIcon,
  CogIcon,
  CreditCardIcon,
  ChartBarIcon,
  ShieldCheckIcon,
} from '@heroicons/react/24/outline';
 
export interface NavItem {
  label: string;
  href: string;
  icon: React.ComponentType<{ className?: string }>;
  permission?: string;   // 必要な権限
  role?: string;         // 必要なロール
  badge?: string;        // バッジ表示
  children?: NavItem[];
}
 
export const navItems: NavItem[] = [
  {
    label: 'ダッシュボード',
    href: '/dashboard',
    icon: HomeIcon,
    // 全ユーザーがアクセス可能
  },
  {
    label: '記事',
    href: '/articles',
    icon: DocumentIcon,
    permission: 'articles:read',
    children: [
      { label: '一覧', href: '/articles', icon: DocumentIcon },
      { label: '新規作成', href: '/articles/new', icon: DocumentIcon, permission: 'articles:create' },
      { label: '下書き', href: '/articles/drafts', icon: DocumentIcon, permission: 'articles:update' },
    ],
  },
  {
    label: 'ユーザー管理',
    href: '/admin/users',
    icon: UsersIcon,
    permission: 'users:read',
  },
  {
    label: '分析',
    href: '/analytics',
    icon: ChartBarIcon,
    permission: 'analytics:read',
  },
  {
    label: '設定',
    href: '/settings',
    icon: CogIcon,
    children: [
      { label: '一般', href: '/settings/general', icon: CogIcon },
      { label: '請求', href: '/settings/billing', icon: CreditCardIcon, permission: 'billing:manage' },
      { label: 'メンバー', href: '/settings/members', icon: UsersIcon, permission: 'users:read' },
      { label: 'セキュリティ', href: '/settings/security', icon: ShieldCheckIcon, role: 'admin' },
    ],
  },
];

4.2 サイドバーの権限フィルタリング

// components/sidebar.tsx - 権限ベースのサイドバー
'use client';
 
import { useAuth } from '@/lib/auth-context';
import { navItems, type NavItem } from '@/lib/navigation';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
 
export function Sidebar() {
  const { can, hasRole, permissionsLoading } = useAuth();
  const pathname = usePathname();
 
  // 権限に基づいてナビゲーション項目をフィルタリング
  const filterNavItems = (items: NavItem[]): NavItem[] => {
    return items
      .filter((item) => {
        // 権限チェック
        if (item.permission) {
          const [resource, action] = item.permission.split(':');
          if (!can(action, resource)) return false;
        }
        // ロールチェック
        if (item.role && !hasRole(item.role)) return false;
        return true;
      })
      .map((item) => ({
        ...item,
        // 子項目も再帰的にフィルタリング
        children: item.children ? filterNavItems(item.children) : undefined,
      }))
      // 子項目がすべてフィルタリングされた親は非表示
      .filter((item) => {
        if (item.children && item.children.length === 0) return false;
        return true;
      });
  };
 
  // 権限ロード中はスケルトン表示
  if (permissionsLoading) {
    return <SidebarSkeleton />;
  }
 
  const visibleItems = filterNavItems(navItems);
 
  return (
    <nav className="w-64 bg-gray-900 text-white min-h-screen p-4">
      {visibleItems.map((item) => (
        <NavLink key={item.href} item={item} pathname={pathname} />
      ))}
    </nav>
  );
}
 
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
  const [isOpen, setIsOpen] = useState(
    item.children?.some((child) => pathname.startsWith(child.href)) ?? false
  );
  const isActive = pathname === item.href;
  const Icon = item.icon;
 
  if (item.children && item.children.length > 0) {
    return (
      <div>
        <button
          onClick={() => setIsOpen(!isOpen)}
          className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg
            hover:bg-gray-800 transition-colors ${
              isActive ? 'bg-gray-800' : ''
            }`}
        >
          <Icon className="w-5 h-5" />
          <span className="flex-1 text-left">{item.label}</span>
          <ChevronIcon className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
        </button>
        {isOpen && (
          <div className="ml-4 mt-1 space-y-1">
            {item.children.map((child) => (
              <NavLink key={child.href} item={child} pathname={pathname} />
            ))}
          </div>
        )}
      </div>
    );
  }
 
  return (
    <Link
      href={item.href}
      className={`flex items-center gap-3 px-3 py-2 rounded-lg
        hover:bg-gray-800 transition-colors ${
          isActive ? 'bg-gray-800 text-white' : 'text-gray-300'
        }`}
    >
      <Icon className="w-5 h-5" />
      <span>{item.label}</span>
      {item.badge && (
        <span className="ml-auto bg-blue-500 text-xs px-2 py-0.5 rounded-full">
          {item.badge}
        </span>
      )}
    </Link>
  );
}

5. Server Components での認可

5.1 サーバーサイドでの権限判定(推奨パターン)

// Next.js Server Components での認可(推奨)
// サーバーサイドで権限チェック → クライアントに不要な情報を送らない
 
// app/articles/[id]/page.tsx
import { auth } from '@/auth';
import { redirect, notFound } from 'next/navigation';
 
export default async function ArticlePage({
  params,
}: {
  params: { id: string };
}) {
  const session = await auth();
  if (!session) redirect('/login');
 
  // 記事データの取得
  const article = await prisma.article.findUnique({
    where: { id: params.id },
    include: {
      author: { select: { name: true, image: true } },
      comments: {
        orderBy: { createdAt: 'desc' },
        take: 20,
      },
    },
  });
 
  if (!article) notFound();
 
  // サーバーサイドで権限を判定
  const permissions = {
    canEdit:
      session.user.role === 'admin' ||
      article.authorId === session.user.id,
    canPublish:
      ['admin', 'editor'].includes(session.user.role) &&
      article.status === 'draft',
    canDelete:
      session.user.role === 'admin',
    canModerateComments:
      ['admin', 'moderator'].includes(session.user.role),
    canViewAnalytics:
      session.user.role === 'admin' ||
      article.authorId === session.user.id,
  };
 
  return (
    <div className="max-w-4xl mx-auto">
      {/* 記事コンテンツ(Server Component) */}
      <article>
        <h1 className="text-3xl font-bold">{article.title}</h1>
        <AuthorInfo author={article.author} />
        <ArticleContent content={article.content} />
      </article>
 
      {/* アクションバー(Client Component、権限は props で渡す) */}
      <ArticleActions
        articleId={article.id}
        status={article.status}
        {...permissions}
      />
 
      {/* コメントセクション */}
      <CommentSection
        comments={article.comments}
        canModerate={permissions.canModerateComments}
      />
 
      {/* 分析データ(権限がある場合のみサーバーで取得) */}
      {permissions.canViewAnalytics && (
        <ArticleAnalytics articleId={article.id} />
      )}
    </div>
  );
}
// クライアントコンポーネント(権限は props で受け取る)
// サーバーで判定済みの権限フラグを使用
'use client';
 
interface ArticleActionsProps {
  articleId: string;
  status: string;
  canEdit: boolean;
  canPublish: boolean;
  canDelete: boolean;
}
 
function ArticleActions({
  articleId,
  status,
  canEdit,
  canPublish,
  canDelete,
}: ArticleActionsProps) {
  return (
    <div className="flex gap-2 border-t border-b py-4 my-8">
      {canEdit && (
        <Link href={`/articles/${articleId}/edit`} className="btn-secondary">
          編集
        </Link>
      )}
      {canPublish && (
        <button
          className="btn-primary"
          onClick={() => publishArticle(articleId)}
        >
          公開
        </button>
      )}
      {canDelete && (
        <button
          className="btn-danger"
          onClick={() => {
            if (confirm('本当に削除しますか?')) {
              deleteArticle(articleId);
            }
          }}
        >
          削除
        </button>
      )}
    </div>
  );
}

5.2 Server Components vs Client Components の使い分け

Server Components と Client Components の認可パターン比較:
項目Server ComponentClient Component
権限チェック場所サーバークライアント
データ取得DB に直接アクセスAPI 経由
セキュリティ高(改ざん不可)低(バイパス可能)
不要データの送信なし権限データの送信が必要
インタラクティブ性なしあり(onClick 等)
パフォーマンス高(JS バンドルなし)権限フェッチのコスト
推奨用途初期表示制御インタラクティブ UI
推奨パターン:

  ① 初期表示: Server Component で権限判定
     → 不要な UI 要素をそもそもレンダリングしない
     → クライアントに権限のないデータを送信しない

  ② インタラクティブ操作: Client Component + props
     → Server Component で判定した権限を props で渡す
     → Client Component はフラグに基づいて表示制御

  ③ 動的な権限変更: Client Component + Context
     → リアルタイムで権限が変わる場合(ロール変更等)
     → AuthContext から権限を取得して表示制御

6. 権限のプリフェッチとキャッシュ

6.1 React Query を使ったプリフェッチ

// lib/permissions-provider.tsx
'use client';
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState, type ReactNode } from 'react';
 
export function PermissionsProvider({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000, // 5分
            gcTime: 30 * 60 * 1000,   // 30分
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} />
      )}
    </QueryClientProvider>
  );
}
// hooks/usePermissions.ts
'use client';
 
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
import { useCallback } from 'react';
 
export function usePermissions() {
  const { data: session } = useSession();
 
  return useQuery({
    queryKey: ['permissions'],
    queryFn: async (): Promise<Set<string>> => {
      const res = await fetch('/api/auth/permissions');
      if (!res.ok) throw new Error('Failed to fetch permissions');
      const data = await res.json();
      return new Set<string>(data.permissions);
    },
    enabled: !!session,
    staleTime: 5 * 60 * 1000,
    gcTime: 30 * 60 * 1000,
    refetchOnWindowFocus: false,
    // ログインイベントで自動更新される
  });
}
 
// 権限の手動更新(ロール変更時、権限変更時)
export function useInvalidatePermissions() {
  const queryClient = useQueryClient();
 
  return useCallback(() => {
    queryClient.invalidateQueries({ queryKey: ['permissions'] });
  }, [queryClient]);
}
 
// 権限のプリフェッチ(ログイン直後に呼ぶ)
export function usePrefetchPermissions() {
  const queryClient = useQueryClient();
 
  return useCallback(() => {
    queryClient.prefetchQuery({
      queryKey: ['permissions'],
      queryFn: async () => {
        const res = await fetch('/api/auth/permissions');
        const data = await res.json();
        return new Set<string>(data.permissions);
      },
    });
  }, [queryClient]);
}

6.2 権限変更時のリアルタイム更新

// hooks/usePermissionsSync.ts
// WebSocket または Server-Sent Events で権限変更を検知
 
'use client';
 
import { useEffect } from 'react';
import { useInvalidatePermissions } from './usePermissions';
import { useSession } from 'next-auth/react';
 
export function usePermissionsSync() {
  const invalidatePermissions = useInvalidatePermissions();
  const { data: session } = useSession();
 
  useEffect(() => {
    if (!session?.user?.id) return;
 
    // Server-Sent Events で権限変更を監視
    const eventSource = new EventSource(
      `/api/auth/permissions/stream?userId=${session.user.id}`
    );
 
    eventSource.addEventListener('permissions_changed', () => {
      // 権限キャッシュを無効化して再取得
      invalidatePermissions();
    });
 
    eventSource.addEventListener('role_changed', () => {
      invalidatePermissions();
    });
 
    eventSource.onerror = () => {
      // 接続エラー時は 5 秒後に再接続
      eventSource.close();
      setTimeout(() => {
        // 再接続ロジック(実装は省略)
      }, 5000);
    };
 
    return () => {
      eventSource.close();
    };
  }, [session?.user?.id, invalidatePermissions]);
}

7. CASL を使った宣言的認可

7.1 CASL の概要

CASL (Isomorphic Authorization) の特徴:
① Isomorphic: サーバーとクライアントで同じルール
② 宣言的: ルールを定義するだけで権限チェック
③ React 統合: Can コンポーネント
④ TypeScript: 型安全な権限定義
⑤ パフォーマンス: ルール評価のキャッシュ
CASL vs 自前実装:
項目CASL自前実装
ルール定義DSL で宣言的コードで手続的
条件付き権限組み込みサポート自前で実装
React 統合@casl/react自前コンポーネント
フィールドレベルサポートあり自前で実装
学習コスト
バンドルサイズ~8KB (gzip)依存なし
推奨複雑な権限体系シンプルな RBAC

7.2 CASL の実装

// lib/ability.ts - CASL による権限定義
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
 
// アクションの型定義
type Actions = 'create' | 'read' | 'update' | 'delete' | 'publish' | 'manage';
 
// サブジェクト(リソース)の型定義
type Subjects =
  | 'Article'
  | 'Comment'
  | 'User'
  | 'Organization'
  | 'all';
 
export type AppAbility = MongoAbility<[Actions, Subjects]>;
 
// ロールに基づく Ability の構築
export function defineAbilityFor(user: {
  id: string;
  role: string;
  organizationId?: string;
}): AppAbility {
  const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
 
  switch (user.role) {
    case 'admin':
      // 管理者は全権限
      can('manage', 'all');
      break;
 
    case 'editor':
      can('read', 'Article');
      can('create', 'Article');
      // 自分の記事のみ編集・削除可能
      can('update', 'Article', { authorId: user.id });
      can('delete', 'Article', { authorId: user.id });
      can('publish', 'Article', { authorId: user.id });
      // コメント
      can('read', 'Comment');
      can('create', 'Comment');
      can('update', 'Comment', { authorId: user.id });
      can('delete', 'Comment', { authorId: user.id });
      break;
 
    case 'viewer':
      can('read', 'Article');
      can('read', 'Comment');
      can('create', 'Comment');
      can('update', 'Comment', { authorId: user.id });
      // 公開記事のみ
      cannot('read', 'Article', { status: 'draft' });
      break;
 
    default:
      // ゲスト: 公開記事の閲覧のみ
      can('read', 'Article', { status: 'published' });
  }
 
  return build();
}
// components/can.tsx - CASL の React コンポーネント
'use client';
 
import { createContext, useContext, type ReactNode } from 'react';
import { createContextualCan } from '@casl/react';
import { type AppAbility } from '@/lib/ability';
 
// Ability コンテキスト
const AbilityContext = createContext<AppAbility>(undefined!);
 
export function AbilityProvider({
  ability,
  children,
}: {
  ability: AppAbility;
  children: ReactNode;
}) {
  return (
    <AbilityContext.Provider value={ability}>
      {children}
    </AbilityContext.Provider>
  );
}
 
export function useAbility(): AppAbility {
  return useContext(AbilityContext);
}
 
// CASL の Can コンポーネント
export const Can = createContextualCan(AbilityContext.Consumer);
 
// 使用例
function ArticleActions({ article }: { article: Article }) {
  return (
    <div className="flex gap-2">
      <Can I="update" this={article}>
        <Link href={`/articles/${article.id}/edit`}>編集</Link>
      </Can>
 
      <Can I="delete" this={article}>
        <button onClick={() => deleteArticle(article.id)}>削除</button>
      </Can>
 
      <Can I="publish" this={article}>
        {article.status === 'draft' && (
          <button onClick={() => publishArticle(article.id)}>公開</button>
        )}
      </Can>
 
      {/* not で否定 */}
      <Can not I="update" this={article}>
        <p className="text-gray-500">この記事を編集する権限がありません</p>
      </Can>
    </div>
  );
}

8. エラーページとフォールバック

8.1 認可エラーページの実装

// app/unauthorized/page.tsx - 403 Unauthorized ページ
import { auth } from '@/auth';
import Link from 'next/link';
 
export default async function UnauthorizedPage() {
  const session = await auth();
 
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center max-w-md">
        <div className="text-6xl font-bold text-gray-300">403</div>
        <h1 className="text-2xl font-bold mt-4">アクセスが拒否されました</h1>
        <p className="text-gray-600 mt-2">
          このページにアクセスする権限がありません
        </p>
 
        {session ? (
          <div className="mt-6 space-y-3">
            <p className="text-sm text-gray-500">
              ログイン中: {session.user?.email}
ロール: {session.user?.role})
            </p>
            <div className="flex gap-3 justify-center">
              <Link href="/dashboard" className="btn-primary">
                ダッシュボードに戻る
              </Link>
              <Link href="/settings" className="btn-secondary">
                権限を確認
              </Link>
            </div>
          </div>
        ) : (
          <div className="mt-6">
            <Link href="/login" className="btn-primary">
              ログインする
            </Link>
          </div>
        )}
 
        <p className="text-xs text-gray-400 mt-8">
          この問題が続く場合は管理者にお問い合わせください
        </p>
      </div>
    </div>
  );
}

9. アンチパターン

9.1 フロントエンドのみでの認可

// ✗ 危険: フロントエンドのみで認可(バックエンドチェックなし)
function DeleteButton({ articleId }: { articleId: string }) {
  const { user } = useAuth();
 
  if (user?.role !== 'admin') return null; // これだけでは不十分!
 
  const handleDelete = async () => {
    // API 側で権限チェックがないと、直接 API を叩かれる
    await fetch(`/api/articles/${articleId}`, { method: 'DELETE' });
  };
 
  return <button onClick={handleDelete}>削除</button>;
}
 
// ✓ 正しい: フロントエンド + バックエンドの両方でチェック
// API 側:
// if (session.user.role !== 'admin') {
//   return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// }

9.2 権限データのハードコーディング

// ✗ 問題: 権限をフロントエンドにハードコード
const ADMIN_EMAILS = ['admin@example.com', 'boss@example.com'];
 
function AdminPanel() {
  const { user } = useAuth();
  if (!ADMIN_EMAILS.includes(user?.email ?? '')) return null;
  return <AdminDashboard />;
}
 
// ✓ 正しい: サーバーから権限を取得
function AdminPanel() {
  const { hasRole } = useAuth();
  if (!hasRole('admin')) return null;
  return <AdminDashboard />;
}

9.3 権限チェックの不整合

// ✗ 問題: フロントとバックで異なるロジック
// フロントエンド: editor でも公開可能
const canPublish = hasRole('editor') || hasRole('admin');
 
// バックエンド: admin のみ公開可能
// if (user.role !== 'admin') return 403;
 
// → エディターが公開ボタンを押すと 403 エラー
// → UX が悪い(ボタンは見えるのにエラー)
 
// ✓ 正しい: 権限の判定ロジックを共有する
// 共通の権限ファイルを定義し、フロントとバックで共有
// または、権限 API からフラグを取得

10. 演習問題

演習 1: 基本 — ルートガードとProtectedRouteの実装(難易度: 基本)

課題:
  Next.js App Router で、Middleware を使ったルートガードと
  ProtectedRoute コンポーネントを実装してください。

要件:
  ① /dashboard は全認証ユーザーがアクセス可能
  ② /admin は admin ロールのみ
  ③ /articles/new は editor と admin のみ
  ④ 未認証ユーザーは /login にリダイレクト(callbackUrl 付き)
  ⑤ 権限不足は /unauthorized にリダイレクト

ヒント:
  → auth() 関数で Middleware 内のセッション取得
  → matcher で対象パスを制限

確認ポイント:
  □ 未認証で /dashboard → /login にリダイレクト
  □ viewer で /admin → /unauthorized にリダイレクト
  □ editor で /articles/new → アクセス可能
  □ ログイン後に元のページに戻る

演習 2: 応用 — 権限ベースの UI 制御(難易度: 応用)

課題:
  AuthContext と Authorized コンポーネントを実装し、
  記事管理画面で権限に基づく UI 制御を行ってください。

要件:
  ① 権限を API からフェッチしてキャッシュ
  ② Authorized コンポーネントで条件表示
  ③ 権限のないボタンは非表示 or disabled
  ④ ナビゲーションの権限フィルタリング
  ⑤ Server Component で初期権限判定

ヒント:
  → React Query で権限をキャッシュ
  → AuthProvider → Authorized → useAuth のレイヤー構成
  → Server Component から props で権限フラグを渡す

確認ポイント:
  □ viewer は記事の閲覧のみ
  □ editor は記事の作成・編集・削除が可能
  □ admin はすべての操作が可能
  □ ナビゲーションが権限に応じて変化

演習 3: 発展 — CASL + リアルタイム権限更新(難易度: 発展)

課題:
  CASL ライブラリを使った宣言的認可と、
  WebSocket を使った権限のリアルタイム更新を実装してください。

要件:
  ① CASL で条件付き認可を定義(自分の記事のみ編集可能等)
  ② Can コンポーネントで UI 制御
  ③ WebSocket で権限変更をリアルタイム通知
  ④ 管理者が他ユーザーのロールを変更 → 即座に UI が更新
  ⑤ フィールドレベルの権限(特定フィールドの編集制限)

ヒント:
  → @casl/ability + @casl/react を使用
  → subject() ヘルパーでリソースの型を指定
  → WebSocket: socket.io-client を使用

確認ポイント:
  □ editor が自分の記事のみ編集できる
  □ admin がロール変更 → 即座に対象ユーザーの UI が変化
  □ フィールドレベルの制限が動作する

11. FAQ

Q1: Server Components と Client Components のどちらで権限チェックすべき?

A: 可能な限り Server Components で行うのが推奨です。

理由:
  → サーバーで判定するため改ざん不可能
  → 権限のないデータをクライアントに送信しない
  → JS バンドルサイズを削減(権限ロジックがサーバー側に)

Client Components を使う場合:
  → インタラクティブな UI(onClick、状態変更)
  → リアルタイムの権限更新が必要な場合
  → ユーザー操作に応じた動的な表示切替

推奨パターン:
  Server Component で権限を判定 → boolean フラグとして
  Client Component に props で渡す

Q2: 権限キャッシュの有効期間はどのくらいが適切?

A: 一般的に 5 分が推奨です。

考慮事項:
  → 短すぎる: API 呼び出しが増え、パフォーマンス低下
  → 長すぎる: 権限変更が反映されるまでの遅延
staleTime用途
1 分厳密な権限制御が必要な場合
5 分(推奨)一般的な Web アプリ
15 分権限変更が稀な場合
リアルタイムWebSocket + invalidate
リアルタイム性が必要な場合:
  → WebSocket / SSE で権限変更を通知
  → invalidateQueries で即座にキャッシュ更新

Q3: ナビゲーションで権限のないページを完全に非表示にすべき?

A: 状況によります。

完全非表示にすべき場合:
  → セキュリティ上、ページの存在を知られたくない
  → 管理者機能(一般ユーザーは知る必要なし)

disabled / グレーアウトにすべき場合:
  → 機能の存在は知ってほしい(アップグレード促進)
  → 「Pro プランにアップグレードすると利用可能」

推奨:
  → セキュリティ系: 完全非表示
  → ビジネス系: disabled + ツールチップで説明

Q4: 大規模アプリでの権限管理のベストプラクティスは?

A: 以下の構成を推奨します。

  ① 権限定義: 一元管理ファイル(lib/permissions.ts)
  ② 権限取得: 専用 API + React Query キャッシュ
  ③ 権限チェック: AuthContext + Authorized コンポーネント
  ④ ルートガード: Middleware(粗いチェック)
  ⑤ ページレベル: Server Components(詳細チェック)
  ⑥ UIレベル: Authorized コンポーネント / Can(CASL)
  ⑦ APIレベル: バックエンドで最終チェック(必須)

権限定義の一元管理例:
  // lib/permissions.ts
  export const PERMISSIONS = {
    ARTICLES_CREATE: 'articles:create',
    ARTICLES_READ: 'articles:read',
    // ...
  } as const;

  // フロントとバックで同じ定数を使用

FAQ

Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?

実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。

Q2: 初心者がよく陥る間違いは何ですか?

基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。

Q3: 実務ではどのように活用されていますか?

このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。


まとめ

項目 ポイント
原則 フロント=UX最適化、バックエンド=セキュリティ保証
ルートガード Next.js Middleware + Auth.js で実装
UI制御 AuthContext + Authorized コンポーネント
Server Components サーバーで権限判定 → props でクライアントに渡す(推奨)
ナビゲーション 権限ベースの再帰的フィルタリング
キャッシュ React Query で 5 分キャッシュ、WebSocket で即時更新
CASL 複雑な条件付き認可に最適。シンプルな RBAC なら自前でも可
必須 フロントエンドの認可は補助的。API 側での認可が必須

次に読むべきガイド


参考文献

  1. OWASP. "Authorization Testing." owasp.org, 2024.
  2. Next.js. "Middleware." nextjs.org/docs/app/building-your-application/routing/middleware, 2024.
  3. CASL. "React Integration." casl.js.org/v6/en/package/casl-react, 2024.
  4. TanStack. "React Query." tanstack.com/query/latest, 2024.
  5. Auth.js. "Session Management." authjs.dev/getting-started/session-management, 2024.
  6. React Router. "Authentication." reactrouter.com/en/main/start/concepts, 2024.