【Flutter】Firestoreのセキュリティルールと複合インデックスの自動作成

こんにちは。広瀬マサルです。

Flutter上で動作する統合フレームワーク**Masamune**を随時更新しています。

https://zenn.dev/mathru/articles/dcd9a52bcf3e94

Firestoreとの連携を強化しているMasamuneですが今回`Firestoreのセキュリティルールと複合インデックスの自動作成機能`を追加したので紹介します。

# masamune_model_firestore_builder

https://pub.dev/packages/masamune_model_firestore_builder

# はじめに

[Masamuneフレームワーク](https://pub.dev/packages/masamune)は「**CLIによるコード生成**」と「**build_runnnerによるコードの自動生成**」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。

データベースに関しても**Firestore**をベースにしたNoSQLデータベースを利用し、わずか１行の更新で`ランタイムDB`と`端末ローカルDB`、`Firestore`を切り替えることが可能な仕組みを提供しています。

Dartコードの記述によってFirestoreのスキーマを簡単に作成することが可能ですが`Firestoreのルールや複合インデックス`は手動で作る必要がありました。

今回[masamune_model_firestore_builder](https://pub.dev/packages/masamune_model_firestore_builder)パッケージを用いることでDartで書いたスキーマと少しの設定からルールとインデックスを自動生成することができるようになります。

これまでの利用と同じ様に下記のような定義でスキーマを定義できます。

```dart
/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  permission: [
    AllowReadModelPermissionQuery.authUsers(),
    AllowWriteModelPermissionQuery.userFromPath(),
  ],
  query: [
    ModelDatabaseQueryGroup(
      name: "age_range_and_gender",
      conditions: [
        ModelDatabaseConditionQuery.greaterThanOrEqualTo("age"),
        ModelDatabaseConditionQuery.lessThan("age"),
        ModelDatabaseConditionQuery.equalTo("gender"),
      ],
    )
  ],
)
abstract class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
    @Default("others") String gender,
  }) = _UserModel;
  const UserModel._();
  
  ~~~~
}
```

ここから下記のようなルールとインデックスが自動生成されます。

```jsx
// firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /user/{uid} {
      allow write: if isSpecifiedUser(uid) && verifyUserModel(getResource());
      allow read: if isAuthUser();
      function verifyUserModel(data) {
        return isDocument(data) && isNullableString(data, "name") && isNullableInt(data, "age") && isString(data, "gender");
      }
    }
    match /{document=**} {
      allow read, write: if false;
    }
    function isSpecifiedUser(userId) {
      return isAuthUser() && request.auth.uid == userId;
    }
    function isDocument(data) {
      return isString(data, "@uid");
    }
    function isString(data, field) {
      return !isNullOrUndefined(data, field) && data[field] is string;
    }
    function isNullableString(data, field) {
      return isNullOrUndefined(data, field) || data[field] is string;
    }
    function isNullableInt(data, field) {
      return isNullOrUndefined(data, field) || data[field] is int;
    }
    function isNullOrUndefined(data, field) {
      return isUndefined(data, field) || data[field] == null;
    }
    function isUndefined(data, field) {
      return !data.keys().hasAll([field]);
    }
    function isAuthUser() {
      return request.auth != null;
    }
    function getResource() {
      return request.resource != null ? request.resource.data : resource.data;
    }
  }
}
```

```json
// firestore.indexes.json

{
  "indexes" : [
    {
      "collectionGroup" : "user",
      "queryScope" : "COLLECTION",
      "fields" : [
        {
          "fieldPath" : "gender",
          "order" : "ASCENDING"
        },
        {
          "fieldPath" : "age",
          "order" : "ASCENDING"
        }
      ]
    }
  ]
}
```

# ルールにより制限可能な箇所

[masamune_model_firestore_builder](https://pub.dev/packages/masamune_model_firestore_builder)によるルール自動作成は下記の項目をチェックしています。※2024年11月28日現在

- データスキーマのキーとその型
- 認証情報（自由に設定可能）
    - 認証情報が存在するユーザーかどうか
    - ユーザーIDとパスの特定の位置の値が一致しているかどうか
    - ユーザーIDとデータの特定の値が一致しているかどうか

これ以上複雑なルール設定は自動生成では行えないためご自身で設定してください。

# 実際にやってみよう

それでは実際の実装を行っていきたいと思います。

## Flutterプロジェクトの作成

まず`katana create`でFlutterプロジェクトを作成します。

```bash
katana create net.mathru.firestorerulestest
```

少し待てばプロジェクトファイルが作成されます。

## Firebaseのプロジェクト作成とFirestoreの有効化

Firebaseのプロジェクトを作成します。

以前のこの記事に記載している通りの方法でプロジェクト作成とFirestoreの有効化までを行います。

https://zenn.dev/mathru/books/d219c9b7cdfd53/viewer/5d6da8

## Authenticationの有効化

今回は認証されているかどうかの検証も行うのでAuthenticationを有効化も行います。

認証方法は`Anonymous`のみ有効にしておきます。

![](https://storage.googleapis.com/zenn-user-upload/f5ba288c2e9d-20241128.png)


## FlutterプロジェクトでのFirebaseの初期設定

`katana.yaml`の下記部分のFirebaseのプロジェクトIDとFirestoreの有効化の設定を追記します。

ルールとインデックスを自動生成するため`generate_rules_and_indexes`を`true`にします。

```yaml
# This section contains information related to Firebase.
# Firebase関連の情報を記載します。
firebase:
  # Set the Firebase project ID.
  # FirebaseのプロジェクトIDを設定します。
  project_id: masamune-test

  # Enable Firebase Firestore.
  # Set [generate_rules_and_indexes] to `true` to automatically generate Firestore security rules and indexes.
  # If [primary_remote_index] is set to `true`, indexes on the console are prioritized and automatic index import is enabled.
  # Firebase Firestoreを有効にします。
  # [generate_rules_and_indexes]を`true`にするとFirestoreのセキュリティルールとインデックスを自動生成します。
  # [primary_remote_index]を`true`にするとコンソール上のインデックスが優先されるため、インデックスの自動インポートが有効になります。
  firestore:
    enable: true # false -> true
    generate_rules_and_indexes: true # false -> true
    primary_remote_index: false

  # Enable Firebase Authentication.
  # Firebase Authenticationを有効にします。
  authentication:
    enable: true # false -> true
```

`katana apply`でkatana.yamlを反映させます。

```bash
katana apply
```

これで準備は完了です。

## データモデルの作成

引き続きデータモデルを作成していきましょう。

下記のコマンドで`ユーザーデータ`を作成します。

```bash
katana code collection user
```

`lib/models/user.dart`が作成されますので、フィールドを追加していきましょう。

そして`CollectionModelPath`の`permission`にルール用の設定を記載、queryに実際に実行する条件付きクエリの内容を記載します。

今回は下記のルールと条件付きクエリで設定します。

- ルール
    - 読み込みは全認証済みユーザーのみ可能
    - 書き込みは認証済みのユーザーのIDと一致するドキュメントIDを持つドキュメントのみ可能
- 条件付きクエリ
    - 年齢と性別でフィルタするクエリ（AND条件）
        - `gender`が指定した条件と一致
        - `age`が指定する範囲に収まる

上記の条件付きクエリはそのまま実行したときに`複合インデックスを作成する必要がある`というエラーが表示されます。

そのためエラーが表示されたときに発行されたURLを開き作成するか、コンソール上で手動で作成する必要がありました。

```dart
// lib/models/user.dart

/// Value for model.
@freezed
@formValue
@immutable
@firebaseDataConnect
// TODO: Set the path for the collection.
@CollectionModelPath(
  "user",
  permission: [
    AllowReadModelPermissionQuery.authUsers(),
    AllowWriteModelPermissionQuery.userFromPath(),
  ],
  query: [
    ModelDatabaseQueryGroup(
      name: "age_range_and_gender",
      conditions: [
        ModelDatabaseConditionQuery.greaterThanOrEqualTo("age"),
        ModelDatabaseConditionQuery.lessThan("age"),
        ModelDatabaseConditionQuery.equalTo("gender"),
      ],
    )
  ],
)
abstract class UserModel with _$UserModel {
  const factory UserModel({
    // TODO: Set the data schema.
    @Default("Guest") String name,
    int? age,
    @Default("others") String gender,
  }) = _UserModel;
  const UserModel._();

  factory UserModel.fromJson(Map<String, Object?> json) =>
      _$UserModelFromJson(json);

  /// Query for document.
  ///
  /// ```dart
  /// appRef.model(UserModel.document(id));       // Get the document.
  /// ref.app.model(UserModel.document(id))..load();  // Load the document.
  /// ```
  static const document = _$UserModelDocumentQuery();

  /// Query for collection.
  ///
  /// ```dart
  /// appRef.model(UserModel.collection());       // Get the collection.
  /// ref.app.model(UserModel.collection())..load();  // Load the collection.
  /// ref.app.model(
  ///   UserModel.collection().data.equal(
  ///     "data",
  ///   )
  /// )..load(); // Load the collection with filter.
  /// ```
  static const collection = _$UserModelCollectionQuery();

  /// Query for form value.
  ///
  /// ```dart
  /// ref.app.form(UserModel.form(UserModel()));    // Get the form controller in app scope.
  /// ref.page.form(UserModel.form(UserModel()));    // Get the form controller in page scope.
  /// ```
  static const form = _$UserModelFormQuery();
}
```

下記コマンドでモデル用のコードを自動生成します。

```bash
katana code generate
```

これによりモデルを扱うDartコードが生成されることに加え`firebase/firestore.rules`と`firebase/firestore.indexes.json`が下記のように更新されます。

```jsx
// firebase/firestore.rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /app/{uid} {
      allow read, write: if false;
    }
    match /user/{uid} {
      allow write: if isSpecifiedUser(uid) && verifyUserModel(getResource());
      allow read: if isAuthUser();
      function verifyUserModel(data) {
        return isDocument(data) && isNullableString(data, "name") && isNullableInt(data, "age") && isString(data, "gender");
      }
    }
    match /{document=**} {
      allow read, write: if false;
    }
    function isSpecifiedUser(userId) {
      return isAuthUser() && request.auth.uid == userId;
    }
    function isDocument(data) {
      return isString(data, "@uid");
    }
    function isString(data, field) {
      return !isNullOrUndefined(data, field) && data[field] is string;
    }
    function isNullableString(data, field) {
      return isNullOrUndefined(data, field) || data[field] is string;
    }
    function isNullableInt(data, field) {
      return isNullOrUndefined(data, field) || data[field] is int;
    }
    function isNullOrUndefined(data, field) {
      return isUndefined(data, field) || data[field] == null;
    }
    function isUndefined(data, field) {
      return !data.keys().hasAll([field]);
    }
    function isAuthUser() {
      return request.auth != null;
    }
    function getResource() {
      return request.resource != null ? request.resource.data : resource.data;
    }
  }
}

```

```json
// firebase/firestore.indexes.json

{
  "indexes" : [
    {
      "collectionGroup" : "user",
      "queryScope" : "COLLECTION",
      "fields" : [
        {
          "fieldPath" : "gender",
          "order" : "ASCENDING"
        },
        {
          "fieldPath" : "age",
          "order" : "ASCENDING"
        }
      ]
    }
  ]
}
```

## 作成されたスキーマのデプロイ

自動生成されたFirestore用の設定をサーバー上にデプロイする必要があります。その場合は下記コマンドを実行してください。

```bash
katana deploy
```

## データを扱うページの作成

定義したモデルを扱うためのページを作成していきましょう。

`lib/pages/home.dart`を下記のように編集して、データの作成や編集を行えるようにしましょう。

今回は認証の仕組みを入れるために`UniversalAppBar`の`actions`にログイン/ログアウトボタンを追加し、ログイン状態をコントロールします。

また、FloatingActionButtonをタップするとログイン済みのユーザーのユーザーIDをドキュメントIDとするドキュメントを新規作成（もしくは更新）します。

`UserModel.collection()`に`ageRangeAndGenderQuery`のメソッドが追加されているのでそれを用いて条件付きクエリの実行を行うことが可能です。

今回の権限ですと非認証ユーザーが`UserModel`のコレクションを読む`permission-denied`エラーになります。

通常は非認証時はデータを読まないといったケアが必要になりますが**今回はあえてエラーを発生させよう**と思います。

```dart
// pages/home.dart

// Flutter imports:
import 'dart:math';

import 'package:firestorerulestest/models/user.dart';
import 'package:flutter/material.dart';

// Package imports:
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';

// Project imports:
import '/main.dart';

part 'home.page.dart';

@immutable
@PagePath("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    final users = ref.app.model(UserModel.collection().ageRangeAndGenderQuery(
        ageGreaterThanOrEqualTo: 0, ageLessThan: 100, genderEqualTo: "others"))
      ..load();
    // Monitor [appAuth] for updates and rebuild the page when updated.
    // [appAuth]の更新状態を監視し更新されたらページが再構築されるようにします。
    ref.page.watch((_) => appAuth, disposal: false);

    ref.page.on(initOrUpdate: () {
      // Restore login information upon restart.
      // 再起動時にログイン情報を復元します。
      appAuth.tryRestoreAuth();
    });

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(
        title: const Text("Firestore Test"),
        subtitle: Text("UserId: ${appAuth.userId}"),
        actions: [
          if (!appAuth.isSignedIn)
            IconButton(
              onPressed: () {
                // Anonymous login.
                // 匿名ログインを行います。
                appAuth.signIn(const AnonymouslySignInAuthProvider());
              },
              icon: const Icon(Icons.login),
            )
          else
            IconButton(
              onPressed: () {
                // When logging out, run context.restartApp to destroy all model references.
                // ログアウトする際はすべてのモデルの参照を破棄するためcontext.restartAppを実行します。
                context.restartApp(onRestart: () async {
                  await appAuth.signOut();
                });
              },
              icon: const Icon(Icons.logout),
            )
        ],
      ),
      body: UniversalListView(
        children: [
          ...users.mapListenable(
            (e) {
              return ListTile(
                title: Text(e.value?.name ?? ""),
                subtitle: Text("Age: ${e.value?.age ?? 0}"),
              );
            },
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final userId = appAuth.userId;
          if (userId == null) {
            return;
          }
          final user = users.create(userId);
          await user.save(
            UserModel(
              name: "User",
              age: Random().rangeInt(10, 80),
              gender: "others",
            ),
          );
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

## デフォルトのModelAdapterの変更

デフォルトの`ModelAdapter`を`FirestoreModelAdapter`に変更します。

また、`AuthAdapter`は`FirebaseAuthAdapter`に変更します。

`lib/adapter.dart`を開き下記を編集してください。

```dart

/// App Model.
///
/// By replacing this with another adapter, the data storage location can be changed.
// TODO: Change the database.
const modelAdapter = FirestoreModelAdapter(
  options: DefaultFirebaseOptions.currentPlatform,
);
// final modelAdapter = RuntimeModelAdapter();

/// App Auth.
///
/// Changing to another adapter allows you to change to another authentication mechanism.
// TODO: Change the authentication.
final authAdapter = FirebaseAuthAdapter(
  options: DefaultFirebaseOptions.currentPlatform,
);
// final authAdapter = RuntimeAuthAdapter();
```

これで実装完了です。

## アプリの実行

アプリを実行して確認してみましょう。

![](https://storage.googleapis.com/zenn-user-upload/b5c6fa9e66c7-20241128.png)

認証していない状態なので起動と同時に`permission-denied`のエラーが発生しました。

![](https://storage.googleapis.com/zenn-user-upload/1ba467e85c06-20241128.png)

ログインしてみましょう。

![](https://storage.googleapis.com/zenn-user-upload/81e3727268eb-20241128.png)

FloatingActionButtonをタップしてください。

データが作成されます。また、認証ユーザーなのでエラーなくデータを取得することができました。

![](https://storage.googleapis.com/zenn-user-upload/4cd36b6ea76b-20241128.png)

Firestoreのコンソール上でもデータが入ってることが確認できます。

![](https://storage.googleapis.com/zenn-user-upload/d7d85423f18c-20241128.png)

それでは`lib/pages/home.dart`に下記の変更を加えてデータをフィルタリングしてみましょう。

また、FloatingActionButtonの中身を変えて自身のユーザーID以外のドキュメントIDを持つドキュメントを保存しようとしたときにどうなるかも確認してみましょう。

```jsx
// lib/pages/home.dart

// Flutter imports:
import 'dart:math';

import 'package:firestorerulestest/models/user.dart';
import 'package:flutter/material.dart';

// Package imports:
import 'package:masamune/masamune.dart';
import 'package:masamune_universal_ui/masamune_universal_ui.dart';

// Project imports:
import '/main.dart';

part 'home.page.dart';

@immutable
@PagePath("/")
class HomePage extends PageScopedWidget {
  const HomePage({
    super.key,
  });

  /// Used to transition to the HomePage screen.
  ///
  /// ```dart
  /// router.push(HomePage.query(parameters));    // Push page to HomePage.
  /// router.replace(HomePage.query(parameters)); // Replace page to HomePage.
  /// ```
  @pageRouteQuery
  static const query = _$HomePageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    final users = ref.app.model(UserModel.collection().ageRangeAndGenderQuery(
        // Filter data by setting ageGreaterThanOrEqualTo 80 or more.
        // ageGreaterThanOrEqualToを80以上にしてデータをフィルタリング。
        ageGreaterThanOrEqualTo: 80,
        ageLessThan: 100,
        genderEqualTo: "others"))
      ..load();
    // Monitor [appAuth] for updates and rebuild the page when updated.
    // [appAuth]の更新状態を監視し更新されたらページが再構築されるようにします。
    ref.page.watch((_) => appAuth, disposal: false);

    ref.page.on(initOrUpdate: () {
      // Restore login information upon restart.
      // 再起動時にログイン情報を復元します。
      appAuth.tryRestoreAuth();
    });

    // Describes the structure of the page.
    return UniversalScaffold(
      appBar: UniversalAppBar(
        title: const Text("Firestore Test"),
        subtitle: Text("UserId: ${appAuth.userId}"),
        actions: [
          if (!appAuth.isSignedIn)
            IconButton(
              onPressed: () {
                // Anonymous login.
                // 匿名ログインを行います。
                appAuth.signIn(const AnonymouslySignInAuthProvider());
              },
              icon: const Icon(Icons.login),
            )
          else
            IconButton(
              onPressed: () {
                // When logging out, run context.restartApp to destroy all model references.
                // ログアウトする際はすべてのモデルの参照を破棄するためcontext.restartAppを実行します。
                context.restartApp(onRestart: () async {
                  await appAuth.signOut();
                });
              },
              icon: const Icon(Icons.logout),
            )
        ],
      ),
      body: UniversalListView(
        children: [
          ...users.mapListenable(
            (e) {
              return ListTile(
                title: Text(e.value?.name ?? ""),
                subtitle: Text("Age: ${e.value?.age ?? 0}"),
              );
            },
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          final userId = appAuth.userId;
          if (userId == null) {
            return;
          }
          // Specify a document ID other than userId.
          // userId以外をドキュメントIDとして指定。
          final user = users.create("OtherId");
          await user.save(
            UserModel(
              name: "User",
              age: Random().rangeInt(10, 80),
              gender: "others",
            ),
          );
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

更新するとデータが消えました。

複合インデックスが必要な条件付きクエリでデータを取得しているにも関わらずエラーが発生しません。

![](https://storage.googleapis.com/zenn-user-upload/0801dad1ea2f-20241128.png)

FloatingActionButtonをタップすると`permission-denied`のエラーが表示されました。

![](https://storage.googleapis.com/zenn-user-upload/8a5fe0e05457-20241128.png)

**これで自動生成されたルール設定と条件付きクエリが正常に動作していることが確認できました。**

# おわりに

Firestoreのルールや複合インデックスは必須でありながら後回しにされがちな設定です。

[Masamuneフレームワーク](https://pub.dev/packages/masamune)を用いることで自動生成できるためほぼ意識せずに実装を進めることが可能になります。

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しているのでissueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet