Skip to content

🗄️ Firestore Security Rules実装とデータアクセス制御 #7

Description

@tocie

概要

Firestoreのセキュリティルールを実装し、@keio.jpドメイン制限とデータアクセス制御を確実にする。

前提条件

  • Firestore にデータ投入済み
  • Firebase Authentication 設定完了
  • 基本的なAngularFire設定完了

タスク一覧

1. Security Rules ファイル作成

  • firestore.rules ファイルの作成
  • ルールの基本構造設計
  • ヘルパー関数の実装
  • データ検証関数の実装

2. 認証・認可ルールの実装

  • @keio.jp ドメイン制限の実装
  • ユーザー認証状態の確認
  • 所有者権限の確認
  • 管理者権限の設計

3. コレクション別アクセス制御

  • users コレクションのルール
  • subjects コレクションのルール(読み取り専用)
  • posts コレクションのルール
  • likes サブコレクションのルール

4. データ検証ルール

  • 必須フィールドの検証
  • データ型の検証
  • 投稿内容の基本検証
  • 文字列長の制限

5. Security Rules のテスト

  • Firebase Security Rules シミュレーターでのテスト
  • ユニットテストの作成
  • 各シナリオのテストケース作成
  • パフォーマンステスト

Security Rules 実装

完全な firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    
    // ========================================
    // Helper Functions
    // ========================================
    
    // 認証済みユーザーかチェック
    function isAuthenticated() {
      return request.auth \!= null;
    }
    
    // @keio.jpドメインのユーザーかチェック
    function isKeioUser() {
      return isAuthenticated() && 
             request.auth.token.email.matches('.*@keio\.jp$');
    }
    
    // リソースの所有者かチェック
    function isOwner(userId) {
      return isKeioUser() && request.auth.uid == userId;
    }
    
    // 管理者かチェック(将来的な拡張用)
    function isAdmin() {
      return isKeioUser() && 
             exists(/databases/$(database)/documents/admins/$(request.auth.uid));
    }
    
    // ========================================
    // Data Validation Functions  
    // ========================================
    
    // ユーザーデータの検証
    function validateUserData() {
      let data = request.resource.data;
      let requiredFields = ['uid', 'email', 'createdAt'];
      let allowedFields = ['uid', 'email', 'displayName', 'facultyType', 'bio', 'createdAt', 'updatedAt'];
      
      return data.keys().hasAll(requiredFields) &&
             data.keys().hasOnly(allowedFields) &&
             data.uid is string &&
             data.email is string &&
             data.email.matches('.*@keio\.jp$') &&
             data.createdAt is timestamp &&
             (data.displayName == null || data.displayName is string) &&
             (data.facultyType == null || data.facultyType is string) &&
             (data.bio == null || (data.bio is string && data.bio.size() <= 500));
    }
    
    // 投稿データの検証
    function validatePostData() {
      let data = request.resource.data;
      let requiredFields = ['title', 'content', 'postType', 'subjectId', 'userId', 'createdAt'];
      let allowedFields = ['title', 'content', 'postType', 'subjectId', 'userId', 'parentPostId', 'likeCount', 'createdAt', 'updatedAt'];
      
      return data.keys().hasAll(requiredFields) &&
             data.keys().hasOnly(allowedFields) &&
             data.title is string &&
             data.title.size() > 0 && data.title.size() <= 200 &&
             data.content is string &&
             data.content.size() > 0 && data.content.size() <= 10000 &&
             data.postType in ['question', 'answer', 'review'] &&
             data.subjectId is string &&
             data.userId is string &&
             data.userId == request.auth.uid &&
             data.createdAt is timestamp &&
             (data.parentPostId == null || data.parentPostId is string) &&
             (data.likeCount == null || (data.likeCount is number && data.likeCount >= 0));
    }
    
    // ========================================
    // Collection Rules
    // ========================================
    
    // Users Collection
    match /users/{userId} {
      // 自分の情報のみ読み書き可能
      allow read, write: if isOwner(userId);
      
      // 新規ユーザー作成時の特別ルール
      allow create: if isKeioUser() && 
                    request.auth.uid == userId &&
                    validateUserData();
    }
    
    // Subjects Collection (読み取り専用)
    match /subjects/{subjectId} {
      // @keio.jpユーザーのみ読み取り可能
      allow read: if isKeioUser();
      
      // 書き込みは管理者のみ(将来的に)
      allow write: if isAdmin();
    }
    
    // Posts Collection
    match /posts/{postId} {
      // @keio.jpユーザーは全投稿を読み取り可能
      allow read: if isKeioUser();
      
      // 投稿作成は認証済み@keio.jpユーザーのみ
      allow create: if isKeioUser() && 
                    request.auth.uid == request.resource.data.userId &&
                    validatePostData();
      
      // 投稿の更新・削除は作成者のみ
      allow update, delete: if isKeioUser() && 
                           request.auth.uid == resource.data.userId;
      
      // Likes Subcollection
      match /likes/{likeUserId} {
        // いいねの読み取りは@keio.jpユーザー全員
        allow read: if isKeioUser();
        
        // いいねの作成・削除は自分のもののみ
        allow create, delete: if isOwner(likeUserId);
      }
    }
    
    // ========================================
    // Admin Collection (将来的な拡張用)
    // ========================================
    match /admins/{adminId} {
      allow read: if isOwner(adminId);
      allow write: if false; // システム管理者のみ
    }
  }
}

Security Rules テスト

テストケース作成

// test/firestore-rules.test.js
const { assertFails, assertSucceeds, initializeTestEnvironment } = require('@firebase/rules-unit-testing');

describe('Firestore Security Rules', () => {
  let testEnv;
  
  beforeEach(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: 'kcc-syllabus-test',
      firestore: {
        rules: fs.readFileSync('firestore.rules', 'utf8'),
      },
    });
  });
  
  afterEach(async () => {
    await testEnv.cleanup();
  });
  
  describe('Authentication', () => {
    test('@keio.jpユーザーはsubjectsを読み取れる', async () => {
      const db = testEnv.authenticatedContext('test-user', {
        email: 'test@keio.jp'
      }).firestore();
      
      await assertSucceeds(db.collection('subjects').doc('test').get());
    });
    
    test('非@keio.jpユーザーはsubjectsを読み取れない', async () => {
      const db = testEnv.authenticatedContext('test-user', {
        email: 'test@gmail.com'
      }).firestore();
      
      await assertFails(db.collection('subjects').doc('test').get());
    });
  });
  
  describe('Users Collection', () => {
    test('ユーザーは自分の情報のみアクセス可能', async () => {
      const db = testEnv.authenticatedContext('user1', {
        email: 'user1@keio.jp'
      }).firestore();
      
      await assertSucceeds(db.collection('users').doc('user1').get());
      await assertFails(db.collection('users').doc('user2').get());
    });
  });
  
  describe('Posts Collection', () => {
    test('適切な投稿データの作成が成功する', async () => {
      const db = testEnv.authenticatedContext('user1', {
        email: 'user1@keio.jp'
      }).firestore();
      
      const validPost = {
        title: 'テスト質問',
        content: 'テスト内容',
        postType: 'question',
        subjectId: 'subject1',
        userId: 'user1',
        createdAt: new Date()
      };
      
      await assertSucceeds(db.collection('posts').add(validPost));
    });
    
    test('不正な投稿データは拒否される', async () => {
      const db = testEnv.authenticatedContext('user1', {
        email: 'user1@keio.jp'
      }).firestore();
      
      const invalidPost = {
        title: '', // 空のタイトル
        content: 'テスト内容',
        postType: 'invalid', // 無効なpostType
        userId: 'user1'
      };
      
      await assertFails(db.collection('posts').add(invalidPost));
    });
  });
});

インデックス設定

firestore.indexes.json

{
  "indexes": [
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        {"fieldPath": "subjectId", "order": "ASCENDING"},
        {"fieldPath": "createdAt", "order": "DESCENDING"}
      ]
    },
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        {"fieldPath": "userId", "order": "ASCENDING"},
        {"fieldPath": "createdAt", "order": "DESCENDING"}
      ]
    },
    {
      "collectionGroup": "posts",
      "queryScope": "COLLECTION",
      "fields": [
        {"fieldPath": "parentPostId", "order": "ASCENDING"},
        {"fieldPath": "postType", "order": "ASCENDING"},
        {"fieldPath": "createdAt", "order": "ASCENDING"}
      ]
    }
  ]
}

デプロイとモニタリング

Rules デプロイ

# Security Rules のデプロイ
firebase deploy --only firestore:rules

# インデックスのデプロイ
firebase deploy --only firestore:indexes

Acceptance Criteria

  • @keio.jpドメイン制限が Security Rules レベルで実装されている
  • 各コレクションの適切なアクセス制御が設定されている
  • データ検証ルールが実装されている
  • Security Rules のテストが通過する
  • Firebase コンソールでルールが正常に動作する
  • パフォーマンステストが通過する
  • インデックスが適切に設定されている

セキュリティテスト項目

  • 未認証ユーザーのアクセス拒否
  • 非@keio.jpユーザーのアクセス拒否
  • 他人のデータへの不正アクセス拒否
  • 不正なデータ形式の投稿拒否
  • XSSパターンの投稿拒否(基本的な検証)

参照

次のステップ

Issue #8: 認証機能とユーザー管理の完全実装

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions