コンテンツにスキップ

リソースレベル権限システム移行ガイド

概要

このガイドは、グローバルロールベースの認証システムからリソースレベル権限システムへの移行手順を説明します。

変更の概要

Before(現行システム)

  • ユーザーはシステム全体で1つのロール(User, LimitedAdmin, ReadOnlyAdmin, FullAccessAdmin)を持つ
  • 管理者権限は全プロジェクト・全企画に対して有効
  • 企画のマネージャーは「最初のメンバー」という暗黙的なルール

After(新システム)

  • ユーザーはリソース(Project, CircleProject)ごとに異なる権限を持てる
  • 細かい粒度の権限(READ, WRITE, DELETE, MANAGE_MEMBERS等)をリソース単位で付与
  • ロールテンプレートにより、よく使う権限セットを簡単に適用可能
  • グローバルロールは下位互換性のために残すが、段階的に廃止

データベーススキーマの変更

新規テーブル

1. ResourcePermission

リソースレベルの権限を管理するテーブル。

model ResourcePermission {
  id           String       @id @default(dbgenerated("(UUID())"))
  user         User         @relation(name: "user_resource_permissions", fields: [userId], references: [id], onDelete: Cascade)
  userId       String
  resourceType ResourceType // PROJECT or CIRCLE_PROJECT
  resourceId   String       // Project.prefix or CircleProject.id
  permissions  String       @db.Text // JSON配列形式で権限を保存
  grantedBy    String?      // 権限を付与したユーザーID
  grantedAt    DateTime     @default(now())
  expiresAt    DateTime?    // 権限の有効期限(オプション)
  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt @default(now())

  @@unique([userId, resourceType, resourceId])
  @@index([resourceType, resourceId])
  @@index([userId])
}

2. ResourceRoleTemplate

プリセット権限セットを定義するテーブル。

model ResourceRoleTemplate {
  id           String       @id @default(dbgenerated("(UUID())"))
  name         String       // ロール名 (例: "Manager", "Editor", "Viewer")
  resourceType ResourceType // 対象リソースタイプ
  permissions  String       @db.Text // JSON配列形式で権限を保存
  description  String?      @db.Text // ロールの説明
  isSystem     Boolean      @default(false) // システム標準ロールかどうか
  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt @default(now())

  @@unique([name, resourceType])
}

新規Enum

enum ResourceType {
  PROJECT        // イベント全体
  CIRCLE_PROJECT // 個別企画
}

enum Permission {
  READ                // 読み取り
  WRITE               // 書き込み・更新
  DELETE              // 削除
  MANAGE_MEMBERS      // メンバー管理
  MANAGE_PERMISSIONS  // 権限管理
  APPROVE             // 承認操作
  CHECKIN             // チェックイン操作
  ALLOCATE_RESOURCES  // リソース割り当て
  VIEW_PRIVATE        // 非公開情報の閲覧
}

マイグレーション手順

Phase 1: スキーマ更新とデータ準備

  1. Prismaスキーマ更新
cd web
npx prisma migrate dev --name add_resource_permissions
  1. ロールテンプレートのシード

web/prisma/seed-resource-roles.ts を作成して実行:

npx tsx prisma/seed-resource-roles.ts

Phase 2: 既存データの移行

  1. 既存権限の移行スクリプト実行

グローバルロールを持つユーザーに対して、適切なリソースレベル権限を付与:

// 移行スクリプト例
async function migrateExistingPermissions() {
  // FullAccessAdminユーザーは全プロジェクトにProjectAdmin権限を付与
  const admins = await prisma.user.findMany({
    where: { role: 'FullAccessAdmin' }
  });

  const projects = await prisma.project.findMany();

  for (const admin of admins) {
    for (const project of projects) {
      await prisma.resourcePermission.create({
        data: {
          userId: admin.id,
          resourceType: 'PROJECT',
          resourceId: project.prefix,
          permissions: JSON.stringify([
            'READ', 'WRITE', 'DELETE', 'MANAGE_MEMBERS',
            'MANAGE_PERMISSIONS', 'APPROVE', 'ALLOCATE_RESOURCES', 'VIEW_PRIVATE'
          ]),
          grantedBy: admin.id,
        }
      });
    }
  }

  // CircleProjectの既存メンバーに権限を付与
  // 最初のメンバー(マネージャー)にManager権限
  // その他のメンバーにMember権限
  const circleProjects = await prisma.circleProject.findMany({
    include: {
      members: {
        orderBy: { id: 'asc' }
      }
    }
  });

  for (const cp of circleProjects) {
    for (let i = 0; i < cp.members.length; i++) {
      const isManager = i === 0;
      const permissions = isManager
        ? ['READ', 'WRITE', 'DELETE', 'MANAGE_MEMBERS', 'MANAGE_PERMISSIONS', 'CHECKIN', 'VIEW_PRIVATE']
        : ['READ', 'CHECKIN'];

      await prisma.resourcePermission.create({
        data: {
          userId: cp.members[i].userId,
          resourceType: 'CIRCLE_PROJECT',
          resourceId: cp.id,
          permissions: JSON.stringify(permissions),
        }
      });
    }
  }
}

Phase 3: コード更新

  1. 認証ヘルパー関数の利用

新しいヘルパー関数を利用してエンドポイントを更新:

// Before
import { requireAdmin } from '@/lib/api/auth';
app.put('/api/project', requireAdmin(), async (c) => {
  // ...
});

// After
import { requireResourcePermission } from '@/lib/api/auth';
app.put('/api/project/:prefix',
  requireResourcePermission('PROJECT', 'prefix', ['WRITE']),
  async (c) => {
    // ...
  }
);
  1. エンドポイント実装例
// 企画の更新エンドポイント
app.put('/api/circle-project/:id',
  requireResourcePermission('CIRCLE_PROJECT', 'id', ['WRITE']),
  async (c) => {
    const id = c.req.param('id');
    const body = await c.req.json();

    // 権限チェック済みなので直接更新可能
    const updated = await prisma.circleProject.update({
      where: { id },
      data: body,
    });

    return c.json(updated);
  }
);

// メンバー管理エンドポイント
app.post('/api/circle-project/:id/members',
  requireResourcePermission('CIRCLE_PROJECT', 'id', ['MANAGE_MEMBERS']),
  async (c) => {
    const id = c.req.param('id');
    const { userId } = await c.req.json();

    await prisma.projectMember.create({
      data: {
        circleProjectId: id,
        userId,
      },
    });

    return c.json({ success: true });
  }
);

Phase 4: 権限管理エンドポイントの実装

  1. リソース権限管理API

以下のエンドポイントを実装:

  • GET /api/resource-permissions - 権限取得
  • POST /api/resource-permissions - 権限付与
  • PUT /api/resource-permissions/:id - 権限更新
  • DELETE /api/resource-permissions/:id - 権限削除
  • GET /api/resource-role-templates - ロールテンプレート一覧

詳細は API_SPECIFICATION.md を参照。

Phase 5: フロントエンド更新

  1. UIコンポーネント作成

  2. リソースごとの権限管理画面

  3. ユーザーに権限を付与するフォーム
  4. ロールテンプレート選択UI

  5. 権限チェックのフック

// useResourcePermission.ts
export function useResourcePermission(
  resourceType: ResourceType,
  resourceId: string,
  requiredPermissions: Permission[]
) {
  const [hasPermission, setHasPermission] = useState(false);

  useEffect(() => {
    fetch(`/api/resource-permissions/check?resourceType=${resourceType}&resourceId=${resourceId}&permissions=${requiredPermissions.join(',')}`)
      .then(r => r.json())
      .then(data => setHasPermission(data.hasPermission));
  }, [resourceType, resourceId, requiredPermissions]);

  return hasPermission;
}

Phase 6: 段階的なグローバルロール廃止

  1. FullAccessAdminの保持

  2. FullAccessAdminは全リソースに対して全権限を持つ(後方互換性)

  3. 新規ユーザーにはリソースレベル権限のみを付与

  4. 古いロールの段階的廃止

  5. LimitedAdmin, ReadOnlyAdminを新規付与しない

  6. 既存ユーザーは適切なリソースレベル権限に移行
  7. 移行完了後、User.role フィールドをオプショナルまたは削除

標準ロールテンプレート

Project用

ロール名 権限
ProjectAdmin READ, WRITE, DELETE, MANAGE_MEMBERS, MANAGE_PERMISSIONS, APPROVE, ALLOCATE_RESOURCES, VIEW_PRIVATE
ProjectManager READ, WRITE, APPROVE, ALLOCATE_RESOURCES, VIEW_PRIVATE
ProjectEditor READ, WRITE, VIEW_PRIVATE
ProjectViewer READ

CircleProject用

ロール名 権限
Manager READ, WRITE, DELETE, MANAGE_MEMBERS, MANAGE_PERMISSIONS, CHECKIN, VIEW_PRIVATE
Editor READ, WRITE, CHECKIN, VIEW_PRIVATE
Member READ, CHECKIN
Viewer READ

使用例

例1: 特定のプロジェクトに管理者を追加

// ProjectManagerロールを付与
await prisma.resourcePermission.create({
  data: {
    userId: 'user-uuid',
    resourceType: 'PROJECT',
    resourceId: 'chibafes2024',
    permissions: JSON.stringify(['READ', 'WRITE', 'APPROVE', 'ALLOCATE_RESOURCES', 'VIEW_PRIVATE']),
    grantedBy: 'admin-uuid',
  }
});

例2: 企画のマネージャーを変更

// 現在のマネージャーの権限を更新
await prisma.resourcePermission.update({
  where: {
    userId_resourceType_resourceId: {
      userId: 'old-manager-uuid',
      resourceType: 'CIRCLE_PROJECT',
      resourceId: 'circle-project-uuid',
    }
  },
  data: {
    permissions: JSON.stringify(['READ', 'CHECKIN']), // Memberに降格
  }
});

// 新しいマネージャーに権限付与
await prisma.resourcePermission.create({
  data: {
    userId: 'new-manager-uuid',
    resourceType: 'CIRCLE_PROJECT',
    resourceId: 'circle-project-uuid',
    permissions: JSON.stringify(['READ', 'WRITE', 'DELETE', 'MANAGE_MEMBERS', 'MANAGE_PERMISSIONS', 'CHECKIN', 'VIEW_PRIVATE']),
    grantedBy: 'admin-uuid',
  }
});

例3: 期限付き権限の付与

// 1ヶ月間のみ編集権限を付与
await prisma.resourcePermission.create({
  data: {
    userId: 'temp-editor-uuid',
    resourceType: 'PROJECT',
    resourceId: 'chibafes2024',
    permissions: JSON.stringify(['READ', 'WRITE']),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30日後
    grantedBy: 'admin-uuid',
  }
});

トラブルシューティング

問題: 既存のエンドポイントが動作しない

原因: グローバルロールベースの認証ミドルウェアを使用している

解決策: 新しい requireResourcePermission ミドルウェアに置き換える

問題: FullAccessAdminが特定のリソースにアクセスできない

原因: 新しい権限チェックロジックの問題

解決策: hasResourcePermission 関数でFullAccessAdminの特別扱いを確認

問題: パフォーマンスの低下

原因: 権限チェックのたびにDBクエリが発生

解決策: - Redis等を使った権限キャッシュの実装 - JWTに権限情報を含める - バッチ処理での権限チェック

ベストプラクティス

  1. 最小権限の原則: 必要最小限の権限のみを付与
  2. 期限の設定: 一時的な権限には必ず有効期限を設定
  3. 監査ログ: 権限の付与・変更・削除をログに記録
  4. 定期的なレビュー: 不要な権限が残っていないか定期的に確認
  5. テンプレートの活用: カスタム権限セットよりもロールテンプレートを優先

参考資料