【Flutter】Firestoreのセキュリティルールと複合インデックスの自動作成
こんにちは。広瀬マサルです。
Flutter上で動作する統合フレームワークMasamuneを随時更新しています。
Firestoreとの連携を強化しているMasamuneですが今回Firestoreのセキュリティルールと複合インデックスの自動作成機能
を追加したので紹介します。
masamune_model_firestore_builder
はじめに
Masamuneフレームワークは「CLIによるコード生成」と「build_runnnerによるコードの自動生成」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。
データベースに関してもFirestoreをベースにしたNoSQLデータベースを利用し、わずか1行の更新でランタイムDB
と端末ローカルDB
、Firestore
を切り替えることが可能な仕組みを提供しています。
Dartコードの記述によってFirestoreのスキーマを簡単に作成することが可能ですがFirestoreのルールや複合インデックス
は手動で作る必要がありました。
今回masamune_model_firestore_builderパッケージを用いることでDartで書いたスキーマと少しの設定からルールとインデックスを自動生成することができるようになります。
これまでの利用と同じ様に下記のような定義でスキーマを定義できます。
/// Value for model.
// TODO: Set the path for the collection.
(
"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.
("Guest") String name,
int? age,
("others") String gender,
}) = _UserModel;
const UserModel._();
~~~~
}
ここから下記のようなルールとインデックスが自動生成されます。
// 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"
}
]
}
]
}
ルールにより制限可能な箇所
masamune_model_firestore_builderによるルール自動作成は下記の項目をチェックしています。※2024年11月28日現在
- データスキーマのキーとその型
- 認証情報(自由に設定可能)
- 認証情報が存在するユーザーかどうか
- ユーザーIDとパスの特定の位置の値が一致しているかどうか
- ユーザーIDとデータの特定の値が一致しているかどうか
これ以上複雑なルール設定は自動生成では行えないためご自身で設定してください。
実際にやってみよう
それでは実際の実装を行っていきたいと思います。
Flutterプロジェクトの作成
まずkatana create
でFlutterプロジェクトを作成します。
katana create net.mathru.firestorerulestest
少し待てばプロジェクトファイルが作成されます。
Firebaseのプロジェクト作成とFirestoreの有効化
Firebaseのプロジェクトを作成します。
以前のこの記事に記載している通りの方法でプロジェクト作成とFirestoreの有効化までを行います。
Authenticationの有効化
今回は認証されているかどうかの検証も行うのでAuthenticationを有効化も行います。
認証方法はAnonymous
のみ有効にしておきます。
FlutterプロジェクトでのFirebaseの初期設定
katana.yaml
の下記部分のFirebaseのプロジェクトIDとFirestoreの有効化の設定を追記します。
ルールとインデックスを自動生成するためgenerate_rules_and_indexes
をtrue
にします。
# 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を反映させます。
katana apply
これで準備は完了です。
データモデルの作成
引き続きデータモデルを作成していきましょう。
下記のコマンドでユーザーデータ
を作成します。
katana code collection user
lib/models/user.dart
が作成されますので、フィールドを追加していきましょう。
そしてCollectionModelPath
のpermission
にルール用の設定を記載、queryに実際に実行する条件付きクエリの内容を記載します。
今回は下記のルールと条件付きクエリで設定します。
- ルール
- 読み込みは全認証済みユーザーのみ可能
- 書き込みは認証済みのユーザーのIDと一致するドキュメントIDを持つドキュメントのみ可能
- 条件付きクエリ
- 年齢と性別でフィルタするクエリ(AND条件)
-
gender
が指定した条件と一致 -
age
が指定する範囲に収まる
-
- 年齢と性別でフィルタするクエリ(AND条件)
上記の条件付きクエリはそのまま実行したときに複合インデックスを作成する必要がある
というエラーが表示されます。
そのためエラーが表示されたときに発行されたURLを開き作成するか、コンソール上で手動で作成する必要がありました。
// lib/models/user.dart
/// Value for model.
// TODO: Set the path for the collection.
(
"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.
("Guest") String name,
int? age,
("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();
}
下記コマンドでモデル用のコードを自動生成します。
katana code generate
これによりモデルを扱うDartコードが生成されることに加えfirebase/firestore.rules
とfirebase/firestore.indexes.json
が下記のように更新されます。
// 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"
}
]
}
]
}
作成されたスキーマのデプロイ
自動生成されたFirestore用の設定をサーバー上にデプロイする必要があります。その場合は下記コマンドを実行してください。
katana deploy
データを扱うページの作成
定義したモデルを扱うためのページを作成していきましょう。
lib/pages/home.dart
を下記のように編集して、データの作成や編集を行えるようにしましょう。
今回は認証の仕組みを入れるためにUniversalAppBar
のactions
にログイン/ログアウトボタンを追加し、ログイン状態をコントロールします。
また、FloatingActionButtonをタップするとログイン済みのユーザーのユーザーIDをドキュメントIDとするドキュメントを新規作成(もしくは更新)します。
UserModel.collection()
にageRangeAndGenderQuery
のメソッドが追加されているのでそれを用いて条件付きクエリの実行を行うことが可能です。
今回の権限ですと非認証ユーザーがUserModel
のコレクションを読むpermission-denied
エラーになります。
通常は非認証時はデータを読まないといったケアが必要になりますが今回はあえてエラーを発生させようと思います。
// 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';
("/")
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.
/// ```
static const query = _$HomePageQuery();
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
を開き下記を編集してください。
/// 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();
これで実装完了です。
アプリの実行
アプリを実行して確認してみましょう。
認証していない状態なので起動と同時にpermission-denied
のエラーが発生しました。
ログインしてみましょう。
FloatingActionButtonをタップしてください。
データが作成されます。また、認証ユーザーなのでエラーなくデータを取得することができました。
Firestoreのコンソール上でもデータが入ってることが確認できます。
それではlib/pages/home.dart
に下記の変更を加えてデータをフィルタリングしてみましょう。
また、FloatingActionButtonの中身を変えて自身のユーザーID以外のドキュメント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),
),
);
}
}
更新するとデータが消えました。
複合インデックスが必要な条件付きクエリでデータを取得しているにも関わらずエラーが発生しません。
FloatingActionButtonをタップするとpermission-denied
のエラーが表示されました。
これで自動生成されたルール設定と条件付きクエリが正常に動作していることが確認できました。
おわりに
Firestoreのルールや複合インデックスは必須でありながら後回しにされがちな設定です。
Masamuneフレームワークを用いることで自動生成できるためほぼ意識せずに実装を進めることが可能になります。
自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください!
また、こちらにソースを公開しているのでissueやPullRequestをお待ちしてます!
また仕事の依頼等ございましたら、私のTwitterやWebサイトで直接ご連絡をお願いいたします!
GitHub Sponsors
スポンサーを随時募集してます。ご支援お待ちしております!
Discussion