Next.jsでのロールベースアクセス制御の苦難 - コンポーネント設計を崩さずに実装する権限管理

はじめに

フロントエンドエンジニアの高橋です。

フロントエンドアーキテクト、開発、マネジメントを中心に、デザインシステムの設計から開発、フロントエンド横断活動まで幅広く担当しております。

Next.js App Routerという選択肢

Next.js 13以降で導入されたApp Routerは、Server ComponentsとClient Componentsを明確に分離することで、新しい設計パターンを可能にしました。

  • Server ComponentsとClient Componentsでデータの受け渡し方法が異なる
  • use clientディレクティブの配置によって、境界線が変わる
  • Middleware、Layout、Page、Componentという多層構造制御の分担

本記事では、Next.js 15.5 App Routerを採用したプロジェクトで、実際に直面した課題と、試行錯誤の末に辿り着いた解決策を共有します。

RBACとは

RBAC(Role-Based Access Control:ロールベースアクセス制御)とは、ユーザーの「ロール(役割)」に基づいてシステムリソースへのアクセスを制御するセキュリティモデルです。個々のユーザーに直接権限を割り当てるのではなく、「管理者」「スタッフ」「閲覧者」などの役割を定義し、その役割に対して権限を付与します。

なぜRBACを選択したのか

アクセス制御モデルには、RBAC以外にもABAC(属性ベースアクセス制御)ReBAC(関係ベースアクセス制御)といった選択肢があります。ABACは時刻・場所・機密レベルなどの属性に基づく柔軟な制御、ReBACは組織階層や共有機能に最適な関係グラフベースの制御を提供します。

しかし、本プロジェクトでは以下の理由からRBACを採用しました:

  1. 権限構造がシンプル:3つのロール(特権管理者、管理者、スタッフ)と機能単位の権限(Permission)で十分
  2. フロントエンド実装の現実性:1回のAPI呼び出しで権限情報を取得でき、Reactのcacheで効率的にメモ化可能
モデル 適した用途 複雑さ フロントエンド実装難易度
RBAC 明確なロール構造、企業内システム
ABAC 動的条件、複雑なビジネスルール
ReBAC 組織階層、共有機能が中心 非常に高

もし、ABACやReBACを採用する場合は、それぞれのモデルに合わせて権限管理を設計する必要があります。 そのためのライブラリも豊富ですが、ライブラリの仕様に引っ張られることも多いです。 RBACはシンプルなので、自前で実装することも難しくありません。 今回は、シンプルにライブラリを使わず「Next.js・React」の機能で実装した例を紹介します。

RBACの基本構成要素

構成要素 説明 具体例
Role(ロール) ユーザーに割り当てられる役割 adminstaff
Resource(リソース) アクセス制御の対象 servicemember
Action(アクション) リソースに対する操作 createreadupdatedelete
Permission(権限) ロールに対するアクセス権 admin: member_read_write

フロントエンドの権限管理はなぜ難しいのか

フロントエンドにおけるRBACの必要性

Webアプリケーションの世界では、RBAC(Role-Based Access Control:ロールベースアクセス制御)は当たり前の概念として定着しています。しかし、その実装の大半はバックエンドに集中しており、フロントエンド側の権限管理は「バックエンドでチェックしているから大丈夫」という理由で後回しにされがちです。

しかし、現代のSaaSアプリケーションでは、フロントエンド側でも適切な権限管理が求められます。その理由は明確です:

  • UX向上:権限のない機能を最初から非表示にすることで、エラー画面への遷移を防ぎ、ユーザー体験を向上させる
  • セキュリティの多層防御:バックエンドだけでなくフロントエンドでも制御することで、セキュリティを強化する
  • アクセシビリティ:権限のないボタンやリンクを非活性化することで、ユーザーに明確なフィードバックを提供する

とはいえ、バックエンドのRBACはセキュリティの最終防衛線であり、フロントエンドの権限チェックはあくまでUX向上のためのものです。この前提を忘れてはいけません。

フロントエンドにおける課題

バックエンドの権限管理が比較的シンプルなのに対し、フロントエンドでは複数の課題が同時に襲いかかります:

課題1: Props地獄とAPIの多重呼び出し

バックエンドでは、エンドポイント単位で権限をチェックすればよいのに対し、フロントエンドでは以下のような複雑な要求に応える必要があります:

  • ページ全体へのアクセス制御(未認可の場合はリダイレクト)
  • ページ内の特定要素の表示/非表示(ボタン、タブ、セクション)
  • 動的なUI制御(ボタンの活性/非活性、フォームのフィールド制御)

深い階層のコンポーネントで権限が必要な場合、素朴に実装すると2つの問題が同時に発生します:

問題1-A: Props地獄(権限データを全コンポーネントに渡す)
// × BAD: 権限データを全コンポーネントに渡す
function MemberListPage() {
  const permissions = await fetchPermissions();

  return (
    <MemberListContainer permissions={permissions}>
      <MemberListHeader permissions={permissions} />
      <MemberListTable permissions={permissions}>
        <MemberRow permissions={permissions}>
          <MemberActions permissions={permissions} />
        </MemberRow>
      </MemberListTable>
    </MemberListContainer>
  );
}

この実装の問題点:

  1. コンポーネントのシグネチャが汚染される:すべてのコンポーネントがpermissionsプロパティを要求
  2. リファクタリングが困難:構造を変更するたびにPropsの受け渡しも修正
  3. テストが複雑化:モックのpermissionsをすべてのテストで用意
問題1-B: APIの多重呼び出し(各コンポーネントで独立してAPI呼び出し)

Props地獄を避けるため、各コンポーネントで独立して権限を取得しようとすると、今度は同一リクエスト内で何度もAPIを呼び出してしまいます:

// × BAD: 各コンポーネントでAPI呼び出し
async function MemberListPage() {
  const permissions1 = await fetch('/api/permissions'); // 1回目
  return <MemberListTable />;
}
async function MemberListTable() {
  const permissions2 = await fetch('/api/permissions'); // 2回目(重複)
  return <MemberRow />;
}
async function MemberRow() {
  const permissions3 = await fetch('/api/permissions'); // 3回目(重複)
  return <MemberActions />;
}

この実装の問題点:

  1. パフォーマンス劣化:同一ページ内で権限APIを10回以上呼び出すことも
  2. サーバー負荷増加:不要なAPI呼び出しでバックエンドに負荷
  3. レスポンス時間の増加:並列呼び出しでも、すべてのリクエストが完了するまで待機

本記事での解決策:

Guardの実装

Guardとは、特定の権限を持つユーザーにのみ表示されるように制御するラッパーコンポーネントです。 権限により対象のUIを表示/非表示にすることができます。

ServerGuardは、Server Componentsで使用するGuardコンポーネントです。 ClientGuardは、Client Componentsで使用するGuardコンポーネントです。

コンポーネントのI/Fを揃えておくことで、ServerGuardとClientGuardを同じように扱えるようにします。 Props設計なども予めI/Fを設計することで、コンポーネントの利用方法などが明確になったり柔軟な拡張が可能になります。 責務を分けることでAI Agentによるコード生成や、コードレビューにも役立ちます。

// サーバー側Guardコンポーネント: Propsリレー不要
import 'server-only';
async function ServerGuard({ permissions, children }) {
  const snapshot = await fetchPermissions();
  const result = evaluatePermission(snapshot, { permissions });
  if (!result.isPermitted) forbidden();
  return <>{children}</>;
}

// 使用例: Propsリレーなしで各コンポーネントが独立して権限制御
import 'server-only';
async function MembersPage() {
  return (
    <div>
      <h1>メンバー一覧</h1>
      <ServerGuard permissions="member_read">
        <MemberList />
      </ServerGuard>
    </div>
  );
}
// PermissionsProvider: Server → Client へのデータ受け渡し
'use client';
export function PermissionsProvider({ snapshot, children }) {
  const value = useMemo(() => hydratePermissions(snapshot), [snapshot]);
  return (
    <PermissionsContext.Provider value={value}>
      {children}
    </PermissionsContext.Provider>
  );
}

// Layout: Server Components で権限を取得し、Provider でラップ
import 'server-only';
async function Layout({ children }) {
  const permissions = await fetchPermissions();
  return (
    <PermissionsProvider snapshot={dehydratePermissions(permissions)}>
      {children}
    </PermissionsProvider>
  );
}

// クライアント側Guardコンポーネント: Propsリレー不要
import 'client-only';
export const ClientGuard = ({ permissions, children }) => {
  const snapshot = usePermissions(); // Context から権限データ取得
  const result = evaluatePermission(snapshot, { permissions });
  return (
    <VisibilityBoundary isVisible={result.isPermitted}>
      {children}
    </VisibilityBoundary>
  );
};

// Page: Client Component での使用例
'use client';
function ServicePage() {
  return (
    <div>
      <h1>サービス一覧</h1>
      <ClientGuard permissions="service_read">
        <AddServiceButton onClick={() => {}} />
      </ClientGuard>
    </div>
  );
}

この設計のメリット:

  1. Propsリレー不要:各コンポーネントが独立して権限を制御
  2. コンポーネントを組み合わせることができる:Guardコンポーネントを組み合わせることで、複雑な権限チェックを実現できます。

React.cache と revalidateTag の連携

キャッシュ戦略の全体像:

// 1. fetchPermissions: React.cache でメモ化(内部で1回のみAPI呼び出し)
import 'server-only';
export const fetchPermissions = cache(async () => {
  const res = await fetch(`${API_BASE}/v1/me/permissions`, {
    next: { tags: ['permissions:me'] }, // ← タグ付与
  });
  return normalizePermissions(await res.json());
});

// 2. 権限変更時: revalidateTag でキャッシュ無効化(権限変更時のみキャッシュを無効化)
export const revalidatePermissions = () => {
  revalidateTag('permissions:me');
};

// 3. 使用例: ユーザーのロール変更後
'use server';
export async function updateUserRole(userId: string, newRole: string) {
  await updateRoleInDatabase(userId, newRole);
  revalidatePermissions(); // ← キャッシュを無効化、次回リクエストで再取得
}

revalidateTag/React.cache のメリット:

  1. 選択的無効化:権限データのみ無効化し、他のキャッシュは保持
  2. 即座に反映:権限変更後、次のリクエストで最新データを取得
  3. シンプルな APIrevalidatePermissions() を呼ぶだけ
  4. サーバー負荷削減:Reactのcacheにより、API呼び出しは1回のみに削減され、2回目以降の権限チェックはキャッシュにより完了します。これにより、サーバー負荷は削減されます。

なぜ必要なのか:

React.cache は同一リクエスト内でのメモ化のみを提供します。リクエストをまたいだキャッシュ戦略(例:Next.jsのData Cache)では、権限変更後も古いデータが返される可能性があります。revalidateTagにより、権限変更のタイミングで明示的にキャッシュを無効化できます。

課題2: ページレベルとコンポーネントレベルの二重管理

権限管理には、大きく2つのレベルがあります:

  1. ページレベル:権限がない場合はページ自体にアクセスさせない(403またはリダイレクト)
  2. コンポーネントレベル:ページ内の特定要素のみ非表示にする(ボタン、タブなど)

権限チェックの場所が分散していると、コードの読み手が「どのタイミングで何を制御しているか」を把握しづらくなり、保守やテストが困難になります:

// × BAD: ページとコンポーネントの責務が混在
export default async function MembersPage() {
  const permissions = await fetchPermissions();

  // ページレベルのチェック?
  if (!permissions.permissions.member_read) {
    redirect('/no-role');
  }

  return (
    <div>
      <h1>メンバー一覧</h1>

      {/* コンポーネントレベルのチェック? */}
      {permissions.permissions.member_read_write && (
        <AddMemberButton />
      )}

      <MemberList />
    </div>
  );
}

この実装の問題点:

  1. 責務が不明確:ページコンポーネントが権限チェックとビジネスロジックの両方を担当
  2. リダイレクト漏れ:条件分岐が複雑になると、リダイレクトし忘れる可能性
  3. テストが困難:ページレベルとコンポーネントレベルの権限を同時にテスト

本記事での解決策:

// members/(DefaultLayout)/layout.tsx
// Layout でページレベルの権限チェック
export default async function MembersLayout({ children }) {
  const snapshot = await fetchPermissions();

  // 早期リターン:ページにアクセスする前にチェック
  const result = evaluatePermission(snapshot, {
    permissions: ['member_read', 'member_read_write'],
  });

  if (!result.isPermitted) {
    redirect('/no-role');
  }

  // 権限OKの場合のみUIレイアウトを構築
  return <PageLayout>{children}</PageLayout>;
}

// members/page.tsx
// Page はビジネスロジックに専念
export default async function MembersPage() {
  return (
    <div>
      <h1>メンバー一覧</h1>

      {/* コンポーネントレベルの制御は Guard で */}
      <ServerGuard permissions="member_read_write">
        <AddMemberButton />
      </ServerGuard>

      <MemberList />
    </div>
  );
}

設計のメリット:

  1. 明確な責務分離:Layout(ページレベル)、Page(ビジネスロジック)、Guard(コンポーネントレベル)
  2. 段階的な実装:既存のPage実装を変更せずに、Layoutに権限チェックを追加可能
  3. パフォーマンス最適化:権限がない場合は早期にリダイレクトし、不要なレンダリングを防ぐ

課題3: チラつきとUX劣化

Server Componentsで権限チェックを行う場合、以下のような懸念がありました:

  1. 初回アクセス(SSR):サーバー側で完全に処理されるため問題なし
  2. クライアント側ナビゲーション(<Link>経由):権限チェック結果が返るまで、既存のLayoutが表示されたままになり、チラつきが発生する可能性
ユーザーが /members にアクセス
  ↓
既存のページが表示されたまま
  ↓
権限チェック完了
  ↓
・権限あり → ページ表示
・権限なし → リダイレクト(チラつき発生!)

この実装の問題点:

  1. 視覚的なノイズ:権限がないページが一瞬表示され、その後リダイレクトされる
  2. ユーザーの混乱:「ページが表示された → すぐ消えた」という悪いUX
  3. セキュリティ懸念:権限のない情報が一瞬でも表示される可能性

本記事での解決策:

Next.jsのloading.tsxとReactのcacheを組み合わせることで、この問題を解決しました:

// members/(DefaultLayout)/loading.tsx
export default function MembersLoading() {
  return (
    <div>
      <Skeleton />
    </div>
  );
}

このシンプルな実装により、以下のフローが実現されます:

クライアント側ナビゲーションのタイムライン:

ユーザーが <Link href="/members"> をクリック
loading.tsx が即座に表示される
         ↓
サーバーで layout.tsx を実行
         - fetchPermissions() → Reactのcacheから取得
         - evaluatePermission() → 権限評価
         - redirect() or 次の処理へ
         ↓
 権限OK → page.tsx をレンダリング
 権限NG → /no-role にリダイレクト

結果: ローディング表示 → ユーザーにはほぼ瞬時に感じられる

責務分離による設計

実現できたこと

上記で紹介した技法により、責務分離による単一責任の原則、保守性やテスト性の向上を図ることができました。 具体的に実現できたことを以下で解説します。

責務分離による保守性の高い実装

権限管理のロジックを明確に分離し、UIコンポーネントと疎結合を意識

// ロール分岐
<ServerGuard authRoles={['super_admin', 'admin']}>
  <AdminPanel />
</ServerGuard>

// 権限分岐(管理者は自動許可)
<ServerGuard permissions="catalog">
  <CatalogCardList />
</ServerGuard>

VisibilityBoundaryによるUI制御

VisibilityBoundaryは、コンポーネントの表示を制御するためのコンポーネントです。

<VisibilityBoundary isVisible={result.isPermitted}>
  <AdminPanel />
</VisibilityBoundary>

「UI制御」と「権限制御」を分離することで、ロジックとUIの責務を明確にし、保守性を高めました。

React 19.2で導入されたActivity APIにより、権限変更時もコンポーネントの状態(フォーム入力、スクロール位置)を保持できるようになりました。 2025年10月時点では、ActivityはClient Componentでのみで動作するため、本プロジェクトでの移行は見送っていますがユースケースが出た場合は移行が可能です。

// 現在の実装
export function VisibilityBoundary({ isVisible, children, fallback }) {
  if (!isVisible) return <>{fallback}</>;
  return <>{children}</>;
}

// 将来の実装
"use client";
export function VisibilityBoundary({ isVisible, children, fallback }) {
  return (
    <>
      {/* fallbackは条件分岐で表示 */}
      {!isVisible && fallback}

      {/* ActivityはchildrenをhiddenにするだけでDOMツリーには保持 */}
      <Activity mode={isVisible ? 'visible' : 'hidden'}>
        {children}
      </Activity>
    </>
  );
}

移行メリット:

  1. 権限変更時もフォーム入力が失われない
  2. タブUIで各タブの状態が保持される
  3. ポーリング処理が権限に応じて自動停止・再開
  4. VisibilityBoundaryの内部実装のみ変更

主要な技術選択

なぜMiddlewareでなくServer Componentsなのか

Next.js公式でも推奨されているように、Middlewareは軽量な認証チェックのみに使用し、詳細な認可ロジックはServer Componentsで実装すべきです。

Middlewareの課題:

  • Edge Runtimeの制約(複雑なAPI呼び出しが困難)
  • すべてのリクエストでチェックが走り、レスポンスタイムに影響
  • デバッグの困難さ

実装の詳細:コードと設計判断

レイヤー構成と責務分離

権限管理の実装は、以下のディレクトリ構造で整理されています:

src/app/_features/permissions/
├── domain/         # ドメイン層(権限(Permission)キー、ロール、型定義)
├── utils/          # ユーティリティ層(正規化、シリアライズ)
├── server/         # サーバー層(fetch + React.cache、ServerGuard)
├── client/         # クライアント層(Provider、ClientGuard)
└── policy.ts       # 権限評価ロジック(純粋関数)
  └── policy.test.ts  # テスト

各層の責務:

  1. domain/:権限(Permission)キーとロールの定義(Single Source of Truth)
export const ALL_PERMISSION_KEYS = [
  'service_read', 'service_read_write',
  'member_read', 'member_read_write',
  'catalog',
] as const;

export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number];

権限制御に必要な要素やI/Fをドメインとすることで、ドメイン層で仕様の明確化やテスト性の向上が期待できます。

hydrate/dehydrateの仕組み

Server ComponentからClient Componentへデータを渡す際、MapオブジェクトはJSON化できないため変換が必要です:

// Server → Client(Map → Record)
export function dehydratePermissions(snapshot: PermissionSnapshot) {
  return {
    role: snapshot.authRoles,
    permissions: snapshot.permissions,
    resourceMap: Object.fromEntries(snapshot.resources), // Map → Record
  };
}

// Client側で復元(Record → Map)
export function hydratePermissions(dehydrated: DehydratedPermissionSnapshot) {
  return {
    authRoles: dehydrated.role,
    permissions: dehydrated.permissions,
    resourceMap: new Map(Object.entries(dehydrated.resources)), // Record → Map
  };
}

使用例:

// layout.tsx (Server Components)
export default async function Layout({ children }) {
  const snapshot = await fetchPermissions();
  return (
    <PermissionsProvider snapshot={dehydratePermissions(snapshot)}>
      {children}
    </PermissionsProvider>
  );
}

// ClientComponent.tsx
'use client';
export function ClientComponent() {
  return (
    <ClientGuard
      permissions="member_read"
      resources={[
        {
          id: 1,
          name: "OPTiM太郎",
          read: false,
          read_write: true,
          // ...
        }
      ]}
      fallback={<div>権限がありません</div>}
    >
      <EditButton />
    </ClientGuard>
  );
}

権限評価ロジック(policy.ts)

policy.tsは、権限管理の心臓部です。純粋関数として実装され、ロール分岐と権限分岐を統合的に処理します:

export function evaluatePermission(
  snapshot: PermissionSnapshot,
  options: EvaluateOptions
): EvaluationResult {
  const { authRoles, permissions } = options;

  // 1. ロールのみ指定 → ロール評価
  // 2. 権限のみ指定 → 権限評価(管理者は自動許可)
  // 3. ロール + 権限 → ロール評価 → 権限評価
}

テスト戦略

純粋関数であるため、すべてのエッジケースを網羅的にテストできます。本機能では、テーブル駆動テストを採用しています:

// policy.test.ts
describe('evaluatePermission - ロール分岐', () => {
  // Arrange: テストデータの定義
  const testCases = [
    {
      name: '特権管理者 → super_admin 要求 → 許可',
      snapshot: superAdminSnapshot,
      options: { authRoles: ROLES.ADMIN },
      expected: { isPermitted: true },
    },
    {
      name: 'スタッフ → super_admin 要求 → 不許可',
      snapshot: staffSnapshot,
      options: { authRoles: ROLES.ADMIN },
      expected: { isPermitted: false, reason: 'Role mismatch' },
    },
    // ... 他のテストケース
  ];

  testCases.forEach(({ name, snapshot, options, expected }) => {
    it(name, () => {
      // Act: 権限評価を実行
      const result = evaluatePermission(snapshot, options);
      // Assert: 結果を検証
      expect(result.isPermitted).toBe(expected.isPermitted);
    });
  });
});

テーブル駆動テストのメリット:

  1. テストケースがデータとして一覧できる
  2. 新しいケースは配列に追加するだけ
  3. 正常系・異常系・境界値を漏れなく管理

最後に

フロントエンドのRBAC実装は、見た目以上に複雑で、多くの落とし穴があります。しかし、適切な設計と技術選択により、コンポーネント設計を崩さずに実装することは可能です。

本記事が、Next.jsでのRBAC実装に悩む開発者の方々にとって、設計のヒントや実装の足がかりとなれば幸いです。

当社では、Next.jsを使って様々なサービスの開発を行っています。技術的な挑戦を楽しみながら、一緒にプロダクトを成長させていける仲間を募集していますので、ご興味のある方は、ぜひご連絡ください。

www.optim.co.jp

参考資料