AIのあとしまつ

実務・技術解説

RBAC(ロールベースアクセス制御)の基本:AIコードで陥りやすい権限設計の落とし穴

RBACの概要と、AIで作ったコードで権限設計が壊れやすい理由を解説。本番化前に確認すべきロール設計のチェックポイントを紹介します。

RBAC 権限設計ロールベースアクセス制御AIコード 権限 バグ本番化 権限設計Supabase RLS ロール管理者 権限 実装アクセス制御 設計Row Level Security

あるスタートアップが、vibe codingで作った社内ツールを本番公開して2日後のことだ。一般ユーザーとして登録したアカウントで、他のユーザーのデータを編集できることが発覚した。「管理者専用」と書いてあるボタンは表示されていなかったが、APIのエンドポイントURLを直接叩くと、誰でも全ユーザーのデータを書き換えられる状態だった。

これはRBACの実装が「フロントエンドだけ」にしか存在しなかったことが原因だ。

RBACとは何か

RBAC(Role-Based Access Control、ロールベースアクセス制御)は「役割(ロール)ごとに、できることを決める」仕組みだ。

管理者は全データを閲覧・編集できる。一般ユーザーは自分のデータだけ見られる。ゲストは参照のみ。こういったルールをシステム全体で一貫して強制するのがRBACだ。

反対に「誰が何にアクセスできるかがコードのあちこちに散らばっている」状態は、穴が生まれやすい。AIが生成するコードはまさにこの状態になりやすい。

AIコードが権限設計で失敗するパターン

パターン1:フロントエンドのみの権限制御

最も多いのがこれだ。ボタンを非表示にする、メニューを出し分ける——これ自体は正しいが、APIの呼び出し元がサーバーサイドでチェックされていなければ意味がない。

// ❌ よくある実装:UIだけ制御して終わり
{user.role === 'admin' && (
  <button onClick={deleteUser}>ユーザーを削除</button>
)}

// このボタンが非表示でも、APIを直接叩けば誰でも削除できる
// fetch('/api/users/123', { method: 'DELETE' })

ブラウザのDevToolsを開けばAPIのURLは簡単にわかる。URLさえわかれば、curlやPostmanで直接叩ける。UIに頼った権限制御は「鍵がかかっていないのに鍵穴が見えない」だけの状態だ。

パターン2:SupabaseのRLS未設定

SupabaseはクライアントからDBに直接アクセスできる仕組みがある。これが便利な反面、RLS(Row Level Security)が設定されていないと、フロントエンドのコードさえあれば誰でも全テーブルを読み書きできる。

AIが「動くコードを作る」ために、RLSを無効にしたまま開発を進めることがある。本番公開のタイミングでこれが残っていると、全データが公開状態だ。

パターン3:管理者チェックがJavaScriptの条件分岐だけ

// ❌ 危険:roleがフロントから改ざんできる場合
const isAdmin = localStorage.getItem('role') === 'admin'

if (isAdmin) {
  // 管理者専用処理
}

localStorage の値はユーザーが自由に書き換えられる。DevToolsのConsoleで localStorage.setItem('role', 'admin') を実行するだけで管理者になれてしまう。

正しいRBACの実装:Next.js + Supabase

ステップ1:DBにロールカラムを持たせる

-- usersテーブルにroleカラムを追加
ALTER TABLE users ADD COLUMN role text NOT NULL DEFAULT 'user'
  CHECK (role IN ('admin', 'user', 'guest'));

-- 最初の管理者を設定(service_role権限で実行)
UPDATE users SET role = 'admin' WHERE id = 'xxxxx';

ロールの変更はサーバーサイドのみから行うように制限する。ユーザー自身が自分のロールを変更できないよう、RLSポリシーを設定する。

ステップ2:SupabaseのRLSポリシーを設定する

-- usersテーブルのRLSを有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- 自分のレコードだけ読める
CREATE POLICY "users_select_own"
  ON users FOR SELECT
  USING (auth.uid() = id);

-- 管理者は全レコードを読める
CREATE POLICY "admins_select_all"
  ON users FOR SELECT
  USING (
    EXISTS (
      SELECT 1 FROM users
      WHERE id = auth.uid() AND role = 'admin'
    )
  );

-- roleカラムは自分では変更できない
CREATE POLICY "users_update_own_except_role"
  ON users FOR UPDATE
  USING (auth.uid() = id)
  WITH CHECK (auth.uid() = id AND role = (SELECT role FROM users WHERE id = auth.uid()));

重要なのは、ロールの判定をDB側(Supabase)で行っている点だ。クライアントがどんな値を送ってきても、DBレベルで弾かれる。

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

// middleware.ts
import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()
  const supabase = createMiddlewareClient({ req, res })

  const {
    data: { session },
  } = await supabase.auth.getSession()

  // 未ログインユーザーを/loginにリダイレクト
  if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  return res
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

ステップ4:APIルートでサーバーサイドのロール検証

// app/api/admin/users/route.ts
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'

export async function DELETE(req: Request) {
  const supabase = createRouteHandlerClient({ cookies })

  // セッション確認
  const { data: { session } } = await supabase.auth.getSession()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // サーバーサイドでロール確認(DBから直接取得)
  const { data: currentUser } = await supabase
    .from('users')
    .select('role')
    .eq('id', session.user.id)
    .single()

  if (currentUser?.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  }

  // 管理者のみここに到達できる
  // ... 削除処理
}

UIとAPIの両方でチェックする二重構造が重要だ。UIのチェックは「ユーザー体験のため」、APIのチェックは「セキュリティのため」という役割分担だ。

MVPで使う3種類のロール

ほとんどのMVPは3種類のロールで十分対応できる。

ロールできること実装上の注意
public(未ログイン)公開コンテンツの閲覧のみRLSで匿名アクセスを明示的に制限
user(一般ユーザー)自分のデータのCRUD、公開機能の利用auth.uid() = user_id で自己所有チェック
admin(管理者)全データのCRUD、設定変更ロール変更はservice_roleのみ可能にする

4つ目、5つ目のロール(モデレーター、プレミアムユーザー等)は、公開後に実際に必要になってから追加するのが現実的だ。最初から複雑な権限設計を作ると実装が膨らみ、バグの温床になる。

AIコードの権限を監査する5つの質問

本番公開前に、以下を必ず確認する。

  1. 「管理者専用API」は本当にサーバーサイドで検証しているか? — APIルートのコードを開いて、ロールチェックの処理があるか確認する。
  2. SupabaseのRLSはすべてのテーブルで有効か?pg_tables を確認するか、ダッシュボードの「Authentication → Policies」で確認する。
  3. ロールの値はDBから取得しているか?localStorage や URLパラメータからロールを判断していないか確認する。
  4. service role keyはサーバーサイドのみで使われているか?NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY という変数名があれば即刻危険信号だ。
  5. 未ログインでアクセスできるURLはどこか? — Next.jsのミドルウェアで保護されているルートの一覧を確認する。

権限設計のミスは「動いているから気づかない」タイプの問題だ。ユーザーから「他の人のデータが見えます」と報告されるまで発覚しないことが多い。AIで作ったコードは特にこの部分が弱いため、本番公開前に意識的に確認するプロセスが必要だ。