Cloud Firestoreのセキュリティルールについて

前回の記事で、Firebaseの認証サービスとCloud Firestoreのセキュリティルールの基本的な使い方を紹介いたしましたが、今回はCloud Firestoreのセキュリティルールについて、すこし突っ込んだ解説をいたします。状況を細かく区切って説明しますので、目的のルールが見つかれば幸いです。

Firebase CLIを利用しよう

今回はFirestoreのセキュリティルールをこまめに変更していきます。Firebaseのコンソールでかえると少し手間ですので、コマンドラインに慣れている方はFirebase CLIをインストールして、コマンドでデプロイできるように準備しておくと楽だと思います。
コマンドラインを使わない方はスキップしてください。

インストール

$ npm install -g firebase-tools

ログイン

$ firebase login

ブラウザが開いてログインとなります。

firestore初期化

$ firebase init firestore

firestoreのプロジェクトを作成するディレクトリを指定しますが、その直下にfirebase.json,firestore.indexes.json,firestore.rulesが生成されます。
セキュリティルールはfirestore.rulesにて記述します。
※firebase.indexes.jsonはfirestoreのカスタムインデックスを利用する際の設定ですので、現在は使いません。

デプロイ

$ firebase deplay firebase deploy --only firestore:rules

※firebase deplay でもOKですが、明示的にセキュリティルールだけ展開する場合は –only firestore:fules フラッグを付与します。

ブラケットの有り無し

ドキュメントもしくはコレクションをブラケットで囲んでいない場合、myDocumentというID(名前)のドキュメントだけに適用されるルールになります。
ドキュメントをブラケットを囲むと、myCollection直下のドキュメント全てに適用されるルールとなります。
ブラケットに囲まれた文字をワイルドカードと呼びますが、ワイルドカードにはドキュメントIDが格納されるので、条件文で利用することがあります。
下のサンプルでは、{anyDocument}となっていますので、anyDocumentというワイルドカードにはドキュメントIDがセットされます。

service cloud.firestore {
    match /databases/{database}/documents {
        //- ブラケット無し
        match /myCollection/myDocument {
            allow read, write: if <条件文>;
        }
        //- ブラケット有り
        match /myCollection/{anyDocument} {
            allow read, write: if <条件文>;
        }
        //- ブラケット有り:ドキュメントIDがkasdlkasdの場合
        match /myCollection/{testDocument} {
            allow update: if testDocument == 'kasdlkas';
        }
    }
}

もしコレクションの子要素(サブコレクション)も含めて、再帰的にルールを適用したい場合はワイルドカードに「=**」を付与します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- 子要素も含めて再帰的にルールを適用
        match /myCollection/{myDocument=**} {
            allow read: if <条件文>;
        }
    }
}

もちろんコレクションをワイルドカードにしても大丈夫です。全てのデータを読み込む権限を与えるコレクションがある場合は

service cloud.firestore {
    match /databases/{database}/documents {
        match /{allChildren=**} {
            allow read;
        }
    }
}

のようになります。

Firestoreの階層構造・matchのネストについて

firestoreは必ず「/databases/DATABASE NAME/documents」 というパスから始まるため、次のような指定も可能。

service cloud.firestore {
    match /databases/{database}/documents/myCollection/{myDocument}/subCollection/{subDocument} {
        allow read, write: if <条件文>;
    }
}

しかしこれだと冗長なので、ネストさせることが推奨されています。

service cloud.firestore {
    match /databases/{database}/documents {
        match /myCollection/{myDocument} {
            match /subCollection/{subDocument} {
                allow read, write: if <条件文>;
            }
        }
    }
}

上と下だと同じルールの適用になります。CSSトのプリプロセッサsassを触った事があればイメージしやすいと思います。ネストさせることで、同じ記述を避けることができ、またルールの見通しも良くなります。

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {

            //- userIDのサブコレクションnameドキュメントの値は
            match /name/value {
                //- ログインしているユーザーは誰でも閲覧可能
                allow read : if request.auth != null;
            }

            //- 誕生日のデータは
            match /birthday/{date} {
                //- ログインしているユーザー自身のものだけ操作可能
                allow read, write : if request.auth.uid === userId;
            }
        }
    }
}

このようにユーザーに関する値操作のルールもネストさせることでわかりやすくなります。

子要素へのルールの適用

Cloud Firestoreのセキュリティルールは、ワイルドカードに「=**」を付与しない限り指定されたドキュメントだけに適用されます。

service cloud.firestore {
    match /databases/{database}/documents {
        match /restaurants/{restaurant} {
            allow read;
        }
    }
}

上のルールの場合、resutaurantドキュメントにを読むことはできますが、その子要素があったとしても、ルールが定義されていないため、だれもアクセスすることができません。
子要素も含めて再帰的、回帰的にルールを適用させたい場合は、ワイルドカードに「=**」を付与します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- ワイルドカードに=**を付与
        match /restaurants/{restaurant=**} {
            allow read;
        }
    }
}

親のドキュメントどサブコレクション中のドキュメントでルールを変える時はネストによって定義します。

service cloud.firestore {
    match /databases/{database}/documents {
        match /restaurants/{restaurant} {
            // 親ドキュメントだけのルール
            allow read;

            match /{allChildren=**} {
                //- 親ドキュメントに内在するドキュメントすべてに適用されるルール
                allow read,write;
            }
        }
    }
}

同一ドキュメントへの複数ルールの適用

同一ドキュメントに対して複数のルールを適用させることが可能です。
例えば、ユーザー情報はオーナーだけが読み書き可能ですが、ペンネームだけはだれにでも表示させたい という状況があったとします。その場合は、まず全部読めない事を適用させてから、ペンネームのドキュメントだけ読ませるようにします。

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {

            //- オーナー意外は読み書きできない
            match /{allChildren=**} {
                allow read,write: if request.auth.uid === userId;
            }

            //- でも、ペンネームだけは読み込みを許可
            match /penname/{data}{
                allow read;
            }
        }
    }
}

条件文について

== や != をつかった一般的なものから組み込み関数等もありますので、簡単ですが羅列いたします。

「==」「!=」 は数字や文字列が同じか確認します。
「== null」「 != null」は、フィールドや値がドキュメントに存在しているか確認します。
「is」 は、値の形を確認します。確認される値は string, int, float, bool, null, timestamp, list, and map です。
「in」 は、mapやlist(配列)に、値が存在するか確認します。
get()関数は、ドキュメントをマップに変換します。コレクションには使えません。
exists()関数は、データベースにドキュメントが存在するか確認します。コレクションには使えません。

それぞれ簡単ですが実例を交えて解説いたします。
※map型は一つのキー(重複なし)に対して、一つの値が定義されているデータ構造です。

「==」「!=」「=== null」「!= null」

これまでのご紹介してきたルールでも使ってきましたので、説明は割愛いたします。

service cloud.firestore {
    match /databases/{database}/documents {
        match /users/{userId} {
            //- ログインしていれば誰でも見れる
            match /public/{public} {
                allow read: if request.auth != null;
            }
            //- ユーザーIDが一致したオーナーのみ読み書きできる
            match /private/{private} {
                allow read,write: if request.auth.uid === userId;
            }
            //- フィールドが存在してなければ書き込める
            match /exists/{exist} {
                allow write: if exist === null;
            }
        }
    }
}

is

値の型を確認します。整数のみや文字列のみといった入力される前のデータのバリデーションに使います。

service cloud.firestore {
    match /databases/{database}/documents {
        //- 名前はテキストの時だけ入力
        match /myCollection/name {
            allow write: if request.resouce.data.name is string;
        }
        //- 日付けは整数として入力
        match /myCollection/birth {
            allow write: if request.resouce.data.birth is int;
        }
    }
}

後でも開設しますがrequest.resouce.dataには入力される値が格納されています。

in

map型やlist型など配列等の中に値が存在するか確認します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- フォームから受け取ったgroupの値がAまたはBの人だけが読み書きできる
        match /myCollection/{myDocument} {
            allow read,write: if resource.data.group in ['A','B']
        }
    }
}

get()

ドキュメントをmap型に変更します。具体的には、データベース中のドキュメントを参照したいときに利用します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- userIdのグループが'admin' or 'member'の時に読み書きできる。
        match /todos/{userId} {
            allow read,write: if get(/databases/$(database)/documents/users/$(userId)).data.group in ['admin','member'];
        }
    }
}

ログインしているユーザーのidから、usersコレクションを参照し、idが一致したユーザー情報が格納されているドキュメンを取得する。
予めデーターベースに格納されているデータを参照するので、利用しやすいと思います。

exists()

ドキュメントが存在しているか確認する。nullはフィールドに値があるかを確認しているのに対し、exists()はドキュメントの存在確認に使用します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- 同一チーム内のユーザーであれば、ユーザー情報を見ることができる
        match /teams/{team}/users/{userId} {
            allow read: if exists(/databases/$(database)/documents/teams/$(team)/users/$(request.auth.uid));
        }
    }
}

resourceとrequest.resourceについて

resourceとrequest.resourceは似ておりどちらもmap型です。ドキュメントの値を参照して条件文を構成したい場合によく使われます。

resource

resourceはデータベースを参照します。読み込みではデータベースに書き込まれたデータマップで、書き込みではデータベースが更新される直前の値を参照します。

request.resource

request.resourceは書き込みリクエストされた値を参照します。requestには、他にはauthやpath,timeがあります。詳しくはrequestについてをご確認ください。

カスタム関数の利用

Cloud Firestoreのセキュリティルール内では関数を利用することが可能です。同じルールが複数存在する場合や複雑な条件の場合に有用です。
ここでは公式ドキュメントのサンプルを利用します。

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectID} {
            //- プロジェクトメンバーまたは、プロジェクトタイプがall-accessになっている場合、更新できる。
      allow update: if request.auth.uid in get(/databases/$(database)/documents/projects/$(projectID)).data.members ||
                     get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
      match /{allChildren=**} {
                //- 上と同じ条件
        allow update: if request.auth.uid in get(/databases/$(database)/documents/projects/$(projectID)).data.members ||
                      get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
      }
    }
  }
}

上記と同じルールをカスタム関数を使うと次のようになります。

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectID} {
            //- アップデートの権限があるか確認する関数
      function canUserUpdateProject() {
        return request.auth.uid in get(/databases/$(database)/documents/project/$(projectID)).data.members ||
         get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
      }

      allow update: if canUserUpdateProject();
      match /{allChildren=**} {
        allow update: if canUserUpdateProject();
      }
    }
  }
}

関数をさらに細かくわけると次のようになり、見通しがよくなります。

service cloud.firestore {
  match /databases/{database}/documents {
    match /projects/{projectID} {
            //- プロジェクトのアクセスタイプが all-acceess になっているか確認
      function isProjectAllAccess() {
        return get(/databases/$(database)/documents/projects/$(projectID)).data.accessType == "all-access";
      }
            //- プロジェクトメンバーにユーザーid(ログインid)が登録されているか確認
      function isUserOfficialMember() {
        return request.auth.uid in get(/databases/$(database)/documents/project/$(projectID)).data.members;
      }
            //- 上の二つのいずれかを満たすか確認
      function canUserUpdateProject() {
        return isUserOfficialMember() || isProjectAllAccess();
      }

      allow update: if canUserUpdateProject();
      match /{allChildren=**} {
        allow update: if canUserUpdateProject();
      }
    }
  }
}

セキュアな書き込みルール

書き込み権限 write は、create, update, deleteに分割できます。データの操作をより細かく管理したい場合に利用します。

service cloud.firestore {
    match /databases/{database}/documents {
        //- writeを分割すると次の用になります。消去(delete)権限は管理者のみにしたい時など、deleteを除きます。
        match /users/{userId} {
            allow create,delete,update: if request.auth.uid == userId;
        }
    }
}

下の例は書き込みリクエストされたデータの簡単なエラーチェックの例です。

service cloud.firestore {
  match /databases/{database}/documents {
    match /rooms/{roomId} {
      match /messages/{messageId} {
                //- 書き込むには nameとtextの二つのフィールドが存在し、いずれもテキストである場合だけ書き込みが可能
        allow write: if request.resource.data.keys().hasAll(["name", "text"])
                     && request.resource.data.size() == 2
                     && request.resource.data.name is string
                     && request.resource.data.text is string;
    }
  }
}
//- ドキュメント参照
var messageRef = db.doc('/rooms/chat/messages');

//- 書き込みできる
messageRef.add({
    name: 'YOKOTE TARO',
    text: 'I LOVE YOKOTE',

});

//- 書き込みできない
messageRef.add({
    name: 'AKITA JIRO',
    text: 'I LOVE AKITA',
    timestamp: Date.now(),
});

同一ドキュメントであるか確認する場合は、resourceとrequest.resourceを利用します。
以下の例は、同一メッセージの場合だけ更新できるようになっています。

service cloud.firestore {
    match /databases/{database}/documents {
        match /rooms/{roomId} {
            //- 同一メッセージの場合だけ更新が可能
            match /messages/{messageId} {
                allow update: if request.resource.data.name == resource.data.name;
            }
        }
    }
}

セキュアな読み込みルール

読み込み権限 read は、get、listに分割することが可能です。

service cloud.firestore {
    match /databases/{database}/documents {
        //- readはget、listに分けることができる
        match /restaurants/{restaurant} {
            allow get,list: if true;
        }
        //- 上と同じ権限になる
        match /restaurants/{restaurant} {
            allow read: if true;
        }

    }
}

where().get()を利用して以下のようにドキュメントをフィルダーして取得することも可能です。

//- name が Yokote Taroのドキュメントを取得
messagesRef.where("name", "==", "Yokote Taro").get().then(function(documentSet) {
    documentSet.forEach(function(document) {
        //- messageにはnameとtextが入っています。
        var message = document.data();
    });
});

メソッドやセキュリティルールについてのリファレンス

ざっとCloud Firestoreのセキュリティルールを解説してきましたが、ここでは紹介できなかった便利なメソッドやその他の解説がたくさんります。  
いずれも便利ですので一読いただければ幸いです。

Cloud Firestore セキュリティルールのリファレンス

NEXT ARTICLE

次こそ、Firestoreを利用して何らかのウェブサービスをご紹介できればと考えています。この際、サービスの公開もFirebaseのホスティング機能を利用しての公開を考えています。Firebaseって本当に便利なサービスですね。