vibe coding+supabaseで匿名キーからのDBアクセスを無効にした話


## 結論

supabaseのanon keyからDB操作権限を全剝奪した。
人々は「こいつはBaaSを運用できない未熟者だ」と罵るだろう。

```sql
-- public スキーマ利用権限の取り消し 
revoke usage on schema public from anon;

-- テーブル、関数、シーケンスの全権限の取り消し
revoke all privileges on all tables in schema public from anon;
revoke all privileges on all routines in schema public from anon;
revoke all privileges on all sequences in schema public from anon;

-- 今後のデフォルト権限からanonを除外
-- postgresロールがpublicスキーマに作成するオブジェクトに対し、anonへの権限付与を停止
alter default privileges for role postgres in schema public
  revoke all on tables from anon;
alter default privileges for role postgres in schema public
  revoke all on routines from anon;
alter default privileges for role postgres in schema public
  revoke all on sequences from anon;
```

## なぜ私はBaaSの喉元に刃を突きつけたのか

SupabaseをはじめとするBaaS（Backend as a Service）の魅力は、なんといってもその手軽さにある。クライアントサイドから直接データベースを操作できるAPIが自動で生成され、爆速でアプリケーションを構築できる。

しかし、その手軽さは諸刃の剣だ。`anon key`はクライアントサイドに埋め込むことが前提のキーであり、言ってしまえば**世界中に公開されている**に等しい。

もちろん、SupabaseにはRLS（Row Level Security）という強力な飛び道具がある。「正しく設定すれば安全だ」という意見は至極もっともだ。しかし、人間は必ずミスを犯す。

- 新しく追加したテーブルにRLSポリシーを設定し忘れた
- ポリシーのSQLに意図しない脆弱性を生んでしまった
- 開発中に一時的にRLSを無効化し、戻し忘れた
- 悪意あるユーザーにフロント側バリデーションが突破された

たった一つの「うっかり」が、致命的な情報漏洩に繋がりかねない。私は、その恐怖に耐えられなかった。BaaSの利便性よりも、データの安全性を絶対的に優先したかったのだ。

## では、どうやってデータを操作するのか？ 答えはフロントエンドにある

クライアントからDBへの直接アクセスを禁じた今、どこでデータを取得するのか。
Supabase Edge Functionsを使うことが公式のプラクティスだろう。

いや、その必要はない。**我々が普段使っているフレームワークに、その答えはすでに用意されているからだ。**

Next.jsの**Server Components**や、React Routerの**loader/action**。これらのサーバーサイドで実行される領域を活用するのだ。

アーキテクチャはこうだ。

1. **クライアント（ブラウザ）からのリクエストは、まずアプリケーションサーバー（Next.jsなど）に到達する。**
2. **Server Componentsやloader関数といった「サーバーサイドの領域」で、`service_role key`を使い、安全にSupabaseへアクセスしてデータを取得する。**
3. **取得したデータをpropsやloader dataとして、HTMLと一緒にクライアントに送信する。**

DB操作のロジックはすべて、我々の管理下にあるサーバーサイドに閉じ込めることができるのだ。

## 実装例

具体的なコードを見てみよう。

### Case 1: Next.js (App Router) のServer Componentsを使う

Next.jsのApp Routerでは、コンポーネントはデフォルトでServer Componentsとなる。ここで直接DBにアクセスする。

データの書き込み (Server Action & Form)
1. バリデーションとDB操作を定義 (app/articles/actions.ts)

```tsx
'use server'

import { createClient } from '@supabase/supabase-js'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

// 入力データのスキーマ定義 by zod
const articleSchema = z.object({
  title: z.string().min(1, 'タイトルは必須です').max(100, '100文字以内で入力してください'),
})

export async function createArticle(formData: FormData) {
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  )

  // 1. バリデーション
  const validatedFields = articleSchema.safeParse({
    title: formData.get('title'),
  })

  if (!validatedFields.success) {
    // エラーメッセージを返す
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }
  
  // 2. DBへ挿入
  const { error } = await supabase
    .from('articles')
    .insert({ title: validatedFields.data.title })

  if (error) {
    return { errors: { db: 'データベースエラーが発生しました。' } }
  }

  // 3. キャッシュを更新して一覧に即時反映
  revalidatePath('/articles')
}
```
2. フォームコンポーネント (app/articles/_components/new-article-form.tsx)

```tsx
'use client'

import { createArticle } from '../actions'

export function NewArticleForm() {
  return (
    // Server Actionを直接formのactionに渡す
    <form action={createArticle}>
      <input type="text" name="title" required />
      <button type="submit">投稿</button>
      {/* エラー表示処理は省略 (useFormState等で実装) */}
    </form>
  )
}
```
## Case 2: React Router の loader & action を使う
データの読み取りはloaderで、書き込みはactionで実装します。

データの読み書き (app/routes/articles.tsx)
```tsx
import { redirect } from "react-router"; // or your server adapter
import { useLoaderData, Form } from "react-router-dom";
import { createClient } from "@supabase/supabase-js";
import { z } from 'zod'

// loader関数 (データの読み取り)
export async function loader() {
  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
  const { data } = await supabase.from("articles").select("*");
  return data;
}

// action関数 (データの書き込み)
export async function action({ request }: { request: Request }) {
  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
  const formData = await request.formData();

  // 1. バリデーション
  const articleSchema = z.object({ title: z.string().min(1) });
  const validatedFields = articleSchema.safeParse({ title: formData.get('title') });

  if (!validatedFields.success) {
    return json({ error: "タイトルは必須です。" }, { status: 400 });
  }

  // 2. DBへ挿入
  const { error } = await supabase.from('articles').insert({ title: validatedFields.data.title });

  if (error) {
    throw new Response("データベースエラー", { status: 500 });
  }

  // 3. 成功したらリダイレクト
  return redirect("/articles");
}

// コンポーネント
export default function Articles() {
  const articles = useLoaderData<typeof loader>();
  
  return (
    <div>
      {/* React RouterのFormコンポーネント */}
      <Form method="post">
        <input type="text" name="title" required />
        <button type="submit">投稿</button>
      </Form>

      <ul>
        {articles?.map((article: any) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  );
}
```
**ポイント:**
- サーバーサイド処理にDBアクセスのロジックを閉じ込める
- これにより、従来の`useEffect`内でfetchする手法に比べ、関心の分離が明確になる

## これはBaaSの否定か、進化か

`anon key`から権限を剥奪する。一見すると、これはBaaSの手軽さを自ら捨て去る愚かな行為に見えるかもしれない。

しかし、これは「BaaSが使えない」のではなく、**モダンフロントエンドフレームワークの思想を深く理解し、BaaSをより堅牢なバックエンドインフラとして活用する**という、一つの進化形だと私は考えている。

これはBaaS運用からの逃げではない。むしろ、より主体的なBaaSとの付き合い方なのだ。