T-CREATOR

Convex で「Permission denied」多発時の原因特定:認可/コンテキスト/引数を総点検

Convex で「Permission denied」多発時の原因特定:認可/コンテキスト/引数を総点検

Convex で「Permission denied」エラーが頻発すると、開発が思うように進まず困ってしまいますよね。本記事では、そのエラーが発生する主な原因を「認可設定」「コンテキスト」「引数」の 3 つの観点から徹底的に解説します。初心者の方でも段階的に理解できるよう、図やコード例を交えながら、具体的な点検ポイントと解決策をご紹介しますので、ぜひ最後までお付き合いください。

背景

Convex は、リアルタイムデータベースと API を統合したバックエンドプラットフォームで、認証・認可の仕組みが組み込まれています。この仕組みにより、セキュアなアプリケーションを簡単に構築できる反面、設定や実装に誤りがあると「Permission denied」エラーが発生しやすくなります。

以下の図は、クライアント側からのリクエストが Convex のバックエンドでどのように処理されるかを示したものです。認証情報が正しく渡され、認可ルールが適切に設定されている場合のみ、データベースへのアクセスが許可されます。

mermaidflowchart LR
  client["クライアント<br/>(React/Vue など)"]
  auth["認証プロバイダ<br/>(Clerk/Auth0 など)"]
  convex["Convex Backend<br/>(Query/Mutation)"]
  authz["認可チェック<br/>(Rules)"]
  db[("Database")]

  client -->|ログイン| auth
  auth -->|トークン発行| client
  client -->|リクエスト + トークン| convex
  convex -->|認証情報確認| authz
  authz -->|OK| db
  authz -->|NG| error["Permission denied"]
  db -->|データ| convex
  convex -->|レスポンス| client

この図から分かるように、認証トークンがクライアントから正しく送信され、バックエンド側で認可ルールが正しく評価される必要があります。どこか一箇所でも欠けていると、エラーが発生してしまうのです。

Convex の認証・認可の仕組み

Convex では、以下の要素が連携して動作します。

  • 認証プロバイダ: Clerk、Auth0、Firebase Auth などの外部サービスと統合
  • 認証情報の伝播: クライアントからのリクエストに含まれる JWT トークン
  • コンテキスト(ctx: サーバー側の関数で利用できる認証情報やデータベースアクセス
  • 認可ルール: 誰がどのデータにアクセスできるかを定義

これらの要素が正しく設定されていないと、エラーが発生します。

課題

「Permission denied」エラーが発生する主な原因は以下の 3 つに分類できます。

1. 認可ルールの不備

Convex では、データベースのテーブルごとに認可ルールを定義できますが、ルールが厳しすぎる、または曖昧な場合にエラーが発生します。

2. コンテキスト(ctx)の誤用

サーバー側の関数(Query/Mutation)では ctx オブジェクトを通じて認証情報やデータベースにアクセスします。しかし、ctx.authnull の場合や、誤ったメソッドを使用している場合にエラーが発生します。

3. 引数の不一致

クライアントから送信される引数が、サーバー側の期待する型や値と一致しない場合、認可チェック以前にエラーが発生することがあります。

以下の図は、これら 3 つの原因がどのように関連しているかを示しています。

mermaidflowchart TD
  req["クライアントリクエスト"]
  argCheck["引数チェック"]
  ctxCheck["コンテキスト確認<br/>(ctx.auth)"]
  authzCheck["認可ルール評価"]
  success["アクセス許可"]
  error["Permission denied"]

  req --> argCheck
  argCheck -->|引数エラー| error
  argCheck -->|OK| ctxCheck
  ctxCheck -->|ctx.auth が null| error
  ctxCheck -->|OK| authzCheck
  authzCheck -->|ルール不一致| error
  authzCheck -->|OK| success

この図から、エラーが発生するポイントは複数あり、それぞれを順番に点検する必要があることが分かりますね。

解決策

それでは、3 つの観点から具体的な解決策を見ていきましょう。各ステップを順番に確認することで、エラーの原因を特定できます。

解決策 1: 認可ルールの点検と修正

認可ルールは convex​/​schema.ts や個別のテーブル定義で設定されます。まず、どのようなルールが設定されているかを確認しましょう。

以下は、認可ルールの基本的な設定例です。

typescript// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    role: v.string(), // "admin" | "user"
  }),

  documents: defineTable({
    title: v.string(),
    content: v.string(),
    ownerId: v.id('users'),
    isPublic: v.boolean(),
  }),
});

次に、Query や Mutation で認可チェックを実装します。

typescript// convex/documents.ts
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';

// ドキュメント取得(認可チェック付き)
export const getDocument = query({
  args: { documentId: v.id('documents') },
  handler: async (ctx, args) => {
    // 認証情報を取得
    const identity = await ctx.auth.getUserIdentity();

    // 未認証の場合はエラー
    if (!identity) {
      throw new Error(
        'Permission denied: Not authenticated'
      );
    }

    // ドキュメントを取得
    const document = await ctx.db.get(args.documentId);

    if (!document) {
      throw new Error('Document not found');
    }

    // 公開ドキュメントまたは所有者のみアクセス可能
    if (
      !document.isPublic &&
      document.ownerId !== identity.subject
    ) {
      throw new Error(
        'Permission denied: Not authorized to view this document'
      );
    }

    return document;
  },
});

このコードでは、以下の点をチェックしています。

  1. ユーザーが認証されているか(ctx.auth.getUserIdentity()
  2. ドキュメントが存在するか
  3. ユーザーがドキュメントの所有者か、または公開ドキュメントか

解決策 2: コンテキスト(ctx)の正しい使用

ctx オブジェクトは、Convex のサーバー側関数で利用できる重要な情報源です。特に ctx.auth を正しく扱うことが重要になります。

以下は、ctx.auth を使用した認証情報の取得例です。

typescript// convex/users.ts
import { query } from './_generated/server';

// 現在のユーザー情報を取得
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    // 認証情報を取得
    const identity = await ctx.auth.getUserIdentity();

    // 未認証の場合は null を返す
    if (!identity) {
      return null;
    }

    // データベースからユーザー情報を検索
    const user = await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('email'), identity.email))
      .first();

    return user;
  },
});

ポイントは以下の通りです。

  • ctx.auth.getUserIdentity() は非同期関数なので await が必要
  • 認証されていない場合は null が返る
  • identity オブジェクトには subject(ユーザー ID)、email などが含まれる

次に、認証が必須の Mutation の例を見てみましょう。

typescript// convex/documents.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

// ドキュメント作成(認証必須)
export const createDocument = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    isPublic: v.boolean(),
  },
  handler: async (ctx, args) => {
    // 認証情報を取得
    const identity = await ctx.auth.getUserIdentity();

    // 未認証の場合はエラーをスロー
    if (!identity) {
      throw new Error(
        'Permission denied: Authentication required'
      );
    }

    // ユーザー情報を取得
    const user = await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('email'), identity.email))
      .first();

    if (!user) {
      throw new Error('User not found in database');
    }

    // ドキュメントを作成
    const documentId = await ctx.db.insert('documents', {
      title: args.title,
      content: args.content,
      ownerId: user._id,
      isPublic: args.isPublic,
    });

    return documentId;
  },
});

このコードでは、以下の流れで処理を行っています。

  1. 認証情報の取得と確認
  2. データベースから対応するユーザー情報を検索
  3. ユーザー情報が存在する場合のみドキュメント作成

解決策 3: 引数の型チェックと検証

Convex では、引数の型を v オブジェクトを使って定義します。型が一致しない場合、Convex が自動的にエラーを返しますが、より詳細な検証を行うことも可能です。

以下は、引数の基本的な型定義例です。

typescript// convex/documents.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const updateDocument = mutation({
  args: {
    documentId: v.id('documents'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    isPublic: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    // 認証チェック
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error(
        'Permission denied: Not authenticated'
      );
    }

    // ドキュメント取得
    const document = await ctx.db.get(args.documentId);

    if (!document) {
      throw new Error('Document not found');
    }

    // 所有者チェック
    const user = await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('email'), identity.email))
      .first();

    if (!user || document.ownerId !== user._id) {
      throw new Error('Permission denied: Not the owner');
    }

    // 更新データを準備
    const updates: any = {};
    if (args.title !== undefined)
      updates.title = args.title;
    if (args.content !== undefined)
      updates.content = args.content;
    if (args.isPublic !== undefined)
      updates.isPublic = args.isPublic;

    // ドキュメントを更新
    await ctx.db.patch(args.documentId, updates);

    return { success: true };
  },
});

引数の型定義では、以下の点に注意してください。

  • v.id("テーブル名") でテーブルの ID 型を指定
  • v.optional() で省略可能な引数を定義
  • v.union() で複数の型を許可

さらに、カスタムバリデーションを追加することもできます。

typescript// convex/documents.ts(カスタムバリデーション付き)
export const createDocumentWithValidation = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    isPublic: v.boolean(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error(
        'Permission denied: Not authenticated'
      );
    }

    // タイトルの長さチェック
    if (args.title.length < 3 || args.title.length > 100) {
      throw new Error(
        'Title must be between 3 and 100 characters'
      );
    }

    // コンテンツの長さチェック
    if (args.content.length < 10) {
      throw new Error(
        'Content must be at least 10 characters'
      );
    }

    // 以降、ドキュメント作成処理
    const user = await ctx.db
      .query('users')
      .filter((q) => q.eq(q.field('email'), identity.email))
      .first();

    if (!user) {
      throw new Error('User not found');
    }

    const documentId = await ctx.db.insert('documents', {
      title: args.title,
      content: args.content,
      ownerId: user._id,
      isPublic: args.isPublic,
    });

    return documentId;
  },
});

このように、引数の型チェックとカスタムバリデーションを組み合わせることで、より堅牢なエラーハンドリングが実現できます。

具体例

ここからは、実際のアプリケーションでよくあるシナリオを元に、「Permission denied」エラーの発生から解決までの流れを見ていきましょう。

シナリオ: ブログアプリでの記事編集

以下の状況を想定します。

  • ユーザーは自分の記事のみ編集可能
  • 管理者は全ての記事を編集可能
  • 未認証ユーザーは編集不可

このシナリオを実装する際の典型的なエラーと解決方法を紹介します。

ステップ 1: スキーマ定義

まず、記事とユーザーのスキーマを定義します。

typescript// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  users: defineTable({
    name: v.string(),
    email: v.string(),
    role: v.union(v.literal('admin'), v.literal('user')),
  }).index('by_email', ['email']),

  posts: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    publishedAt: v.optional(v.number()),
  }).index('by_author', ['authorId']),
});

このスキーマでは、ユーザーのロール(admin または user)と、記事の作成者を管理しています。

ステップ 2: 記事編集の Mutation

次に、記事を編集する Mutation を実装します。

typescript// convex/posts.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const updatePost = mutation({
  args: {
    postId: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // 1. 認証チェック
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error(
        'Permission denied: Authentication required'
      );
    }

    // 2. ユーザー情報取得
    const user = await ctx.db
      .query('users')
      .withIndex('by_email', (q) =>
        q.eq('email', identity.email)
      )
      .first();

    if (!user) {
      throw new Error('User not found in database');
    }

    // 3. 記事取得
    const post = await ctx.db.get(args.postId);
    if (!post) {
      throw new Error('Post not found');
    }

    // 4. 認可チェック: 作成者または管理者のみ編集可能
    const isOwner = post.authorId === user._id;
    const isAdmin = user.role === 'admin';

    if (!isOwner && !isAdmin) {
      throw new Error(
        'Permission denied: Not authorized to edit this post'
      );
    }

    // 5. 記事更新
    const updates: any = {};
    if (args.title !== undefined)
      updates.title = args.title;
    if (args.content !== undefined)
      updates.content = args.content;

    await ctx.db.patch(args.postId, updates);

    return { success: true, postId: args.postId };
  },
});

このコードでは、以下の 5 つのステップで認可チェックを行っています。

  1. ユーザーが認証されているか確認
  2. データベースからユーザー情報を取得
  3. 編集対象の記事を取得
  4. ユーザーが記事の作成者または管理者か確認
  5. 問題なければ記事を更新

ステップ 3: クライアント側の実装

クライアント側では、Convex の React Hooks を使用して Mutation を呼び出します。

typescript// app/components/EditPost.tsx
'use client';

import { useMutation } from 'convex/react';
import { api } from '@/convex/_generated/api';
import { useState } from 'react';
import type { Id } from '@/convex/_generated/dataModel';

interface EditPostProps {
  postId: Id<'posts'>;
  initialTitle: string;
  initialContent: string;
}

export function EditPost({
  postId,
  initialTitle,
  initialContent,
}: EditPostProps) {
  const [title, setTitle] = useState(initialTitle);
  const [content, setContent] = useState(initialContent);
  const [error, setError] = useState<string | null>(null);

  const updatePost = useMutation(api.posts.updatePost);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);

    try {
      await updatePost({
        postId,
        title,
        content,
      });
      alert('記事を更新しました');
    } catch (err) {
      // エラーハンドリング
      if (err instanceof Error) {
        setError(err.message);
      } else {
        setError('予期しないエラーが発生しました');
      }
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor='title'>タイトル</label>
        <input
          id='title'
          type='text'
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>

      <div>
        <label htmlFor='content'>本文</label>
        <textarea
          id='content'
          value={content}
          onChange={(e) => setContent(e.target.value)}
        />
      </div>

      {error && <p style={{ color: 'red' }}>{error}</p>}

      <button type='submit'>更新</button>
    </form>
  );
}

クライアント側では、エラーが発生した場合にメッセージを表示し、ユーザーに適切なフィードバックを提供します。

よくあるエラーパターンと対処法

以下の表は、頻出するエラーパターンとその対処法をまとめたものです。

#エラーメッセージ原因対処法
1Permission denied: Not authenticated認証情報が取得できないクライアント側の認証設定を確認
2User not found in database認証済みだがユーザーレコードが未作成初回ログイン時にユーザーレコードを作成
3Permission denied: Not authorized to edit this post作成者でも管理者でもない認可ロジックを見直し
4Document not found存在しないドキュメント IDクライアント側で ID の妥当性を確認
5Argument validation failed引数の型が不正引数の型定義を確認

エラー解決の具体例: ユーザーレコードの自動作成

「User not found in database」エラーを防ぐため、初回ログイン時に自動的にユーザーレコードを作成する仕組みを実装できます。

typescript// convex/users.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

// ユーザーレコードを作成または取得
export const getOrCreateUser = mutation({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      throw new Error('Not authenticated');
    }

    // 既存ユーザーを検索
    const existingUser = await ctx.db
      .query('users')
      .withIndex('by_email', (q) =>
        q.eq('email', identity.email!)
      )
      .first();

    if (existingUser) {
      return existingUser;
    }

    // 新規ユーザーを作成
    const userId = await ctx.db.insert('users', {
      name: identity.name ?? 'Unknown',
      email: identity.email!,
      role: 'user', // デフォルトは一般ユーザー
    });

    return await ctx.db.get(userId);
  },
});

このコードでは、認証情報からユーザーを検索し、存在しない場合は新規作成します。これにより、「User not found」エラーを防ぐことができますね。

クライアント側では、アプリケーション起動時にこの Mutation を呼び出します。

typescript// app/components/AuthProvider.tsx
'use client';

import { useConvexAuth, useMutation } from 'convex/react';
import { api } from '@/convex/_generated/api';
import { useEffect } from 'react';

export function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const { isAuthenticated } = useConvexAuth();
  const getOrCreateUser = useMutation(
    api.users.getOrCreateUser
  );

  useEffect(() => {
    if (isAuthenticated) {
      // 認証後にユーザーレコードを作成または取得
      getOrCreateUser().catch((err) => {
        console.error('Failed to create user:', err);
      });
    }
  }, [isAuthenticated, getOrCreateUser]);

  return <>{children}</>;
}

この実装により、ユーザーが認証されると自動的にユーザーレコードが作成され、以降の操作で「User not found」エラーが発生しなくなります。

デバッグのベストプラクティス

「Permission denied」エラーをデバッグする際は、以下の手順を踏むと効率的です。

1. ログ出力で認証情報を確認

サーバー側で console.log を使って認証情報を確認しましょう。

typescriptexport const debugAuth = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    console.log('Identity:', identity);

    if (identity) {
      console.log('User ID (subject):', identity.subject);
      console.log('Email:', identity.email);
      console.log('Name:', identity.name);
    }

    return identity;
  },
});

このクエリを実行すると、Convex のダッシュボードまたはローカル開発環境のログで認証情報を確認できます。

2. エラーメッセージを詳細化

エラーメッセージには、具体的な情報を含めることが重要です。

typescriptexport const updatePostWithDetailedError = mutation({
  args: {
    postId: v.id('posts'),
    title: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      throw new Error(
        'Permission denied: User is not authenticated'
      );
    }

    const user = await ctx.db
      .query('users')
      .withIndex('by_email', (q) =>
        q.eq('email', identity.email!)
      )
      .first();

    if (!user) {
      throw new Error(
        `Permission denied: User with email ${identity.email} not found in database`
      );
    }

    const post = await ctx.db.get(args.postId);

    if (!post) {
      throw new Error(
        `Post with ID ${args.postId} not found`
      );
    }

    if (
      post.authorId !== user._id &&
      user.role !== 'admin'
    ) {
      throw new Error(
        `Permission denied: User ${user._id} is not authorized to edit post ${args.postId} (owner: ${post.authorId})`
      );
    }

    // 更新処理
    await ctx.db.patch(args.postId, { title: args.title });

    return { success: true };
  },
});

詳細なエラーメッセージにより、どの段階で問題が発生しているかが一目瞭然になります。

3. テストケースの作成

Convex では、サーバー側の関数をテストできます。以下は、認可ロジックのテストケース例です。

typescript// convex/posts.test.ts
import { test, expect } from 'vitest';
import { convexTest } from 'convex-test';
import schema from './schema';
import { api } from './_generated/api';

test('updatePost: 作成者は編集可能', async () => {
  const t = convexTest(schema);

  // ユーザーと記事を作成
  const userId = await t.run(async (ctx) => {
    return await ctx.db.insert('users', {
      name: 'Test User',
      email: 'test@example.com',
      role: 'user',
    });
  });

  const postId = await t.run(async (ctx) => {
    return await ctx.db.insert('posts', {
      title: 'Test Post',
      content: 'Content',
      authorId: userId,
    });
  });

  // 認証情報を設定
  t.withIdentity({
    subject: 'test-subject',
    email: 'test@example.com',
    name: 'Test User',
  });

  // 記事更新を実行
  const result = await t.mutation(api.posts.updatePost, {
    postId,
    title: 'Updated Title',
  });

  expect(result.success).toBe(true);
});

テストを作成することで、認可ロジックが意図通りに動作しているかを確認できます。

まとめ

Convex で「Permission denied」エラーが発生した際の原因特定と解決方法について、3 つの観点から詳しく解説しました。

認可ルールの設定では、誰がどのデータにアクセスできるかを明確に定義することが重要です。コンテキスト(ctx)を正しく使用し、特に ctx.auth.getUserIdentity() で認証情報を取得する際は、null チェックを忘れずに行いましょう。引数の型定義とバリデーションも、エラーを未然に防ぐために欠かせません。

エラーが発生したときは、ログ出力で認証情報を確認し、詳細なエラーメッセージを設定することで、問題箇所を素早く特定できます。テストケースを作成しておけば、リファクタリング時にも安心ですね。

これらのポイントを押さえることで、Convex アプリケーションのセキュリティと安定性が大きく向上します。今後の開発にぜひお役立てください。

関連リンク