[Flutter] Automatic creation of Firestore security rules and composite indexes - mathru.net | App Development with Flutter, Unity/Music and Video Production/Material Distribution
2024-12-05

[Flutter] Automatic creation of Firestore security rules and composite indexes

Hello. I'm Masaru Hirose.

I am constantly updating Masamune, an integration framework that runs on Flutter.

Hello. My name is Masaru Hirose.
https://medium.comhttps://medium.com
title

As we strengthen Masamune's integration with Firestore, I'd like to introduce the newly added Firestore security rules and automatic composite index creation feature.

masamune_model_firestore_builder

Define a builder to describe Firestore rules; build using the masamune_annotation annotation.
https://pub.devhttps://pub.dev
title

Introduction

The Masamune framework is a framework running on Flutter that reduces code writing as much as possible through CLI code generation and automatic code generation using build_runner to enhance the stability and speed of app development.

For databases, it provides a mechanism that uses a NoSQL database based on Firestore, allowing you to switch between runtime DB, local device DB, and Firestore with just a single line update.

While Firestore schemas can be easily created by writing Dart code, Firestore rules and composite indexes had to be created manually.

Now with the masamune_model_firestore_builder package, rules and indexes can be automatically generated from schemas written in Dart with minimal configuration.

You can define schemas using the same definition style as before, as shown below.

/// 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"),
      ],
    )
  ],
)
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._();
  
  ~~~~
}

From here, rules and indexes will be automatically generated as shown below.

// 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;
    }
  }
}
// firestore.indexes.json

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

Areas that can be restricted by rules

Rule auto-generation by masamune_model_firestore_builder checks the following items. *As of November 28, 2024

  • Data schema keys and their types
  • Authentication information (freely configurable)
    • Whether a user with authentication information exists
    • Whether the user ID matches a specific value in the path
    • Whether the user ID matches a specific value in the data

More complex rule settings cannot be auto-generated, so please configure them yourself.

Let's try it out

Now let's proceed with the actual implementation.

Creating a Flutter project

First, create a Flutter project using katana create.

katana create net.mathru.firestorerulestest

After a short wait, the project files will be created.

Creating a Firebase Project and Enabling Firestore

Create a Firebase project.

Follow the steps outlined in the previous article to create the project and enable Firestore.

Enabling Authentication

Since we'll be verifying whether users are authenticated or not, we'll need to enable Authentication.

We'll only enable Anonymous as the authentication method.

image block

Initial Firebase Setup in Flutter Project

Add the Firebase project ID and Firestore enablement settings in the following section of katana.yaml.

Set generate_rules_and_indexes to true to automatically generate rules and indexes.


# 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

Apply the katana.yaml settings using katana apply.

katana apply

The preparation is now complete.

Creating the Data Model

Let's continue by creating the data model.

Use the command below to create the user data.

katana code collection user

The file lib/models/user.dart will be created, so let's add fields to it.

Then, we'll specify rule settings in the permission parameter of CollectionModelPath, and specify the actual conditional query contents in query.

We'll set up the following rules and conditional queries:

  • Rules
    • Reading is only possible for all authenticated users
    • Writing is only possible for documents with document IDs matching the authenticated user's ID
  • Conditional Queries
    • Query filtering by age and gender (AND condition)
      • gender matches the specified condition
      • age falls within the specified range

When executing the above conditional query directly, you'll get an error saying that you need to create a composite index.

Previously, you had to either open the URL provided when the error appeared or manually create it in the console.

// 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"),
      ],
    )
  ],
)
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();
}

Run the following command to auto-generate the code for the model.

katana code generate

In addition to generating Dart code for handling the model, firebase/firestore.rules and firebase/firestore.indexes.json will be updated as shown below.

// 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;
    }
  }
}
// firebase/firestore.indexes.json

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

Deploying the Created Schema

The automatically generated Firestore settings need to be deployed to the server. Execute the following command to do this.

katana deploy

Creating a Page to Handle Data

Let's create a page to handle the defined model.

Let's edit lib/pages/home.dart as shown below to enable data creation and editing.

In this case, we'll add login/logout buttons to the actions of UniversalAppBar to implement authentication and control the login state.

Also, when the FloatingActionButton is tapped, it will create (or update) a document using the logged-in user's user ID as the document ID.

Since ageRangeAndGenderQuery method has been added to UserModel.collection(), we can use it to execute conditional queries.

With the current permissions, unauthenticated users will get a permission-denied error when trying to read the UserModel collection.

Normally, we would need to handle cases where data shouldn't be read when not authenticated, but this time we'll intentionally let the error occur.

// 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),
      ),
    );
  }
}

Changing the Default ModelAdapter

Change the default ModelAdapter to FirestoreModelAdapter.

Also, change the AuthAdapter to FirebaseAuthAdapter.

Open lib/adapter.dart and edit as follows.


/// 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();

This completes the implementation.

Running the App

Let's run the app and check it out.

image block

Since we're not authenticated, a permission-denied error occurred immediately upon launch.

image block

Let's try logging in.

image block

Please tap the FloatingActionButton.

Data will be created. Also, since you are an authenticated user, you can retrieve the data without any errors.

image block

You can also verify that the data has been entered in the Firestore console.

image block

Now let's modify lib/pages/home.dart as shown below to try filtering the data.

Also, let's change the contents of the FloatingActionButton to see what happens when we try to save a document with a document ID other than our own user ID.

// 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),
      ),
    );
  }
}

When refreshed, the data disappeared.

Despite retrieving data with a conditional query that requires a composite index, no error occurs.

image block

When the FloatingActionButton was tapped, a permission-denied error was displayed.

image block

This confirms that the automatically generated rule settings and conditional queries are working properly.

Conclusion

While Firestore rules and composite indexes are essential, they tend to be settings that get pushed to the back burner.

By using the Masamune framework, these can be automatically generated, allowing you to proceed with implementation almost without having to think about them.

I made it for my own use, but if you think it fits your implementation philosophy, by all means, use it!

Also, I releasing the source here, so issues and PullRequests are welcome!

If you have any further work requests, please contact me directly through my Twitter or website!

Offers app development and apps using Flutter and Unity. Includes information on music and videos created by the company. Distribution of images and video materials. We also accept orders for work.
https://mathru.nethttps://mathru.net
title

GitHub Sponsors

Sponsors are always welcome. Thank you for your support!

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.comhttps://github.com
title

[Album] KANIMISO A2K

Works mathru Music Vocaloid Album Gackpo Camui Miku Hatsune GUMI

◀︎ Next
[Information] The album "Bushido -It is the way of the Samurai-" will be available on subscriptions.

Blog mathru Music Album Gackpo Camui

▶︎ Previous