23
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

セットで学ぶ!Firestoreのクエリとルール

Posted at

背景

Firebaseを使うと、バックエンドを構築する手間が省けて楽ちん。
しかし楽ちんだからといって、大切なことを怠るとと大きな危険が待っている。
大切なデータが保存されているDBにアクセスするAPIがどこからでもコールできる、ということは非常に危ないこと。
そのセキュリティの穴を塞いでいるのが、Firestoreの「ルール」である。
楽をするからには、徹底的にセキュリティに穴を塞がなければならないが、このルールの記述が簡単ではない。
特に、こういうクエリを投げるときにはこういうルールを記載しなければならない、というようにクエリとルールが分かりやすく結びついているドキュメントがほとんどない。
Vue.js + Firebaseでの開発も非常に苦労したので、私が調べて学んだことをメモしておこうと思った。
万が一、誰かの役に立つことができればとても幸いである。

データの取得

ケース① 自分のユーザIDに紐づくデータを取得する

1つ目なので、少し詳細に記載する。
image.png

ドキュメントIDがFirebase Authenticationのuidと紐づいている場合に、自分のデータを取得したいとき

呼び出し元
// 認証情報を取得
const user = firebase.auth().currentUser;

// クエリ発行
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow get: if request.auth != null && userId == request.auth.uid;
   }

}

###ルール書き方
match /users/{userId}の部分が、usersコレクションの、すべてのドキュメントIDに適用されるルールであることを示す。
また、この「userId」の部分は、存在する全てのドキュメントIDに置き換えられる。

allow get:の部分は、ルールの種類を記載している。下記の種類がある。

ルール 説明
get 単一のドキュメントのデータが取得されるとき
list 複数のドキュメントのデータが取得されるとき
read 単一、複数の区別なく、データが取得されるとき(get + list)
create 新しいドキュメントが作成されるとき
update 既存のドキュメントが変更されるとき
delete 既存のドキュメントが削除されるとき
write ドキュメントが新規作成・変更・削除されるとき(create + update + delete)

基本的には、必要最低限のルールのみを使用して記載する。(例えば、単一のドキュメントのみ取得すればよい場合、read・listを使用しない)

allow get: の後のif~が条件式を記載する部分となっている。
「true」のときアクセスが許可され、「false」のときアクセスが禁止される。
これを満たすように条件式を記述する。

「request」や「resource」は、あらかじめ用意されているオブジェクトである。
簡単にいうと、「request」はAPIをコールするフロントエンド側に関連するデータを持つオブジェクト。
「resource」はFirebase側、つまりサーバ側に関連するデータを持つオブジェクトと言ったところだろうか。

ケース解説

基本的にユーザ情報を管理するときは、ドキュメントIDをFirebase Authenticationのuidと同一にし、本ケースのようなルールを設定するのが良い。
公式のドキュメントを含めたいくつかの記事で、上記のようなルールの例が挙げられている。
本ルールについては、条件式に下記を含む。

  • request.auth   : リクエスト元ユーザの認証情報
  • request.auth.uid  : 認証済みリクエスト元ユーザのuid

つまり、リクエスト元ユーザが認証完了していて(request.auth != null)、かつドキュメントIDが認証済みリクエスト元ユーザのuidと等しい(userId == request.auth.uid)とき、アクセスを許可する。

ケース② 共通の値をデータに持つ複数の別ユーザのデータを取得する

image.png

自分と同じ、team: "red" であるユーザ全てのデータを取得したいとき

呼び出し元
// 認証情報を取得
const user = firebase.auth().currentUser;

// 自分のuserデータを取得 ⇒ teamを取得
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();
const myTeam = myInfo.data().team;

// 同一のteamであるuserにクエリ発行
const datas = firebase.firestore().collection('users').where('team', '==', myTeam);
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow get: if request.auth != null && userId == request.auth.uid;

      // 下記を追加
      allow read: if resource.data.team == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team;
   }

}

ケース解説

Firestoreでは、where句が使え、これにより条件に合致したドキュメントのリストを取得することができる。
ただし、where句を使う際には、ルールとの関係性を注意する必要がある。
詳細は下記ページの、「ルールはフィルタではない」の項目を見てほしい。

上記の例でいうと、呼び出し側のwhere句について、where('team', '==', 'red')みたいな記載をしたとき、「自分と同じ'red'チームである」という最終的な条件が合致していたとしても、取得することができない。
なぜなら、ルールはフィルタではないからだ。
取得したいユーザのteam(resource.data.team)と、リクエスト元ユーザのteam(get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team)が同一の値である、という条件をwhere句で保証しなければならない
where('team', '==', 'red')だと、ルール側で記載した条件が真であることを保証できないため、最終的にwhere句内ではtrueとなる条件だとしても、ルールによってアクセスが拒否される。

今回、ルール内で「get()」という関数がある。
記載例のように引数にデータのパスを指定することで、データベース内にある任意のデータを参照することができる。

ケース③ 特定の権限を持つユーザのみアクセスが許可されたデータを取得する

image.png
image.png

hasChatAuth: true であるユーザのみが、自チームのchatデータを取得したいとき

呼び出し元
// 認証情報を取得
const user = firebase.auth().currentUser;

// 自分のuserデータを取得 ⇒ チャットアクセスの権限の有無を取得
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();
const myData = myInfo.data();
const myTeamId = myData.teamId;
const isAuthed = myData.hasChatAuth;

// チャットアクセスの権限を持つuserのみクエリ発行
if(isAuthed) const datas = firebase.firestore().collection('chat').doc(myTeamId).get();
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow get: if request.auth != null && userId == request.auth.uid;
   }

   // 下記を追加
   match /chat/{chatId} {
      allow get: if chatId == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.teamId      // ここはケース(1) + ケース(2)みたいなもの
                 && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.hasChatAuth == true;  // ここで権限チェック 
   }

}

###ケース解説
今まで解説したものの組み合わせで実装できる。
ケースとしては今までと少し異なり、今回はDBに格納されているユーザの特定の値をチェックし、その値によりアクセス可能かどうかを判定する。
条件外のユーザがアクセスしようとすると、アクセス拒否されるため、フロントエンド側では、値が条件に合致するユーザのみクエリを発行するようにするか、アクセス拒否時の例外処理を組み込むようにする。

ケース④ 共通の値をデータに持つ別ユーザのサブコレクションのデータを取得する

image.png
image.png

自分と同じ、team: "red" である特定ユーザのサブコレクションhistoryのデータを取得したいとき

呼び出し元
// 取得したいユーザの名前など
const fetchedUserName = "任意任男";

// 認証情報を取得
const user = firebase.auth().currentUser;

// 自分のuserデータを取得 ⇒ teamを取得
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();
const myTeam = myInfo.data().team;

// 取得したいユーザのドキュメントIDを取得
const users = await firebase.firestore().collection('users').where('team', '==', myTeam).get();
const docId = users.docs.find(element => element.data().name === fetchedUserName).id;

// サブコレクションhistoryのすべてのドキュメントを取得
const data = await firebase.firestore().collection('users').doc(docId).collection('history').get();
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow get: if request.auth != null && userId == request.auth.uid;
      allow read: if resource.data.team == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team;

      // サブコレクション特有のルールを設定する場合、下記のように、入れ子にしてmatch文を追加する。
      match /history/{historyId} {
      	allow read: if get(/databases/$(database)/documents/users/$(userId)).data.team == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team;
      }
   }

}

###ケース解説
Firestoreでは、サブコレクションというものを作成でき、DBを階層化できる。
users直下のreadの条件式と異なっているのは、「resource」が示す位置が異なっているから。
users直下の「resource」はusersコレクションのドキュメントのデータを指し、history直下の「resource」はhistoryサブコレクションのドキュメントのデータを指す。
users配下のドキュメントには「team」変数があるのでresource.data.teamで「team」を参照できるが、history配下のドキュメントには、「team」変数がないので、resource.data.teamでは「team」を参照できない。
そのため、users配下のドキュメントのデータ「team」を参照するため、「get()」関数を使用する必要がある。

階層データについては、再帰ワイルドカードみたいなものもある。
下記が参考になる。

データの作成・更新・削除

ケース⑤ 自分のドキュメントを新規作成する

ドキュメントIDがFirebase Authenticationのuidと同一の、ユーザデータを登録したい。

呼び出し元
// 認証情報を取得
const user = firebase.auth().currentUser;

// データを設定する
await firebase.firestore().collection('users').doc(user.uid).set({
   user: "taro",
   age: 20,
   hobby: "pachinko"
});
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow create: if request.auth != null && userId == request.auth.uid;
   }

}

ケース⑥ 別ユーザの特定のデータを更新する

image.png

同じ team: "red" の他ユーザの、evaluation: "〇〇" の値のみ変更したい(evaluationの値は"A"か"B"か"C")

呼び出し元
// 取得したいユーザの名前など
const fetchedUserName = "任意任男";

// 認証情報を取得
const user = firebase.auth().currentUser;

// 自分のuserデータを取得 ⇒ teamを取得
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();
const myTeam = myInfo.data().team;

// 取得したいユーザのドキュメントIDを取得
const users = await firebase.firestore().collection('users').where('team', '==', myTeam).get();
const docId = users.docs.find(element => element.data().name === fetchedUserName).id;

// ここまではケース(4)と同じ

// データを更新する
await firebase.firestore().collection('users').doc(docId).update({
   evaluation: "B"
});
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      allow get: if request.auth != null && userId == request.auth.uid;
      allow read: if resource.data.team == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team;

      // 以下を追加
      allow update: if resource.data.team == get(/databases/$(database)/documents/users/$(request.auth.uid)).data.team   // ここは(2)と同じ
                    && request.resource.data.keys().hasAll(['age', 'area', 'evaluation', 'name', 'team'])    // update後、キーが消されていない
                    && request.resource.data.keys().size() == 5   // update後、キーが追加されていない
                    && request.resource.data.age == resource.data.age     // update前後でageの値が同一
                    && request.resource.data.area == resource.data.area   // update前後でareaの値が同一
                    && request.resource.data.name == resource.data.name   // update前後でnameの値が同一
                    && request.resource.data.team == resource.data.team   // update前後でteamの値が同一
                    && request.resource.data.evaluation.matches('A|B|C')  // 変更するevaluationの値は"A"か"B"か"C"のみ
   }

}

###ケース解説
ルールで新たに出てきた関数をまとめると、

関数 説明
keys() map配列から、mapのキーのみの配列を取得する
hasAll() 呼び出し元の配列が、引数で与えた配列の要素を全て含んでいるかどうかを判定する
size() 呼び出し元の配列のサイズを取得する
matches() 呼び出し元の文字列が、引数で与えた正規表現に合致するかどうかを判定する

その他の関数については、下記の記事が、体系的にまとめてあって分かりやすい。

要は、「evaluationの値の変更のみ」、かつ「evaluationの許された値への変更のみ」を許可するため、様々な条件式を記載している。
ルールには決して隙があってはならない。
自分が想定もしない操作をしてくるユーザがいるかもしれないので、慎重に設定する必要がある。

また、今回、クエリ側では「evaluation」しか設定していないが、ルールのrequest.resource.dataに「age」やら「area」やら存在していていいのか?と思った方がいるかもしれない。
ルールのrequest.resource.dataは、リクエストが成功したときに、そうなっているであろうデータの未来値を表している。
このような「変更後のデータ」があるために、変更前後でデータを比較することができる。

ケース⑦ 自分のドキュメント内の、ある配列について、1つだけ要素を追加する

image.png

attendanceTimes: [〇〇,〇〇,…] に1つだけ要素を追加したい。

呼び出し元
// 認証情報を取得
const user = firebase.auth().currentUser;

// データを更新する
await firebase.firestore().collection('users').doc(users.uid).update({
   // FieldValue.arrayUnion で、配列に結合
   attendanceTimes: firebase.firestore.FieldValue.arrayUnion(firebase.firestore.Timestamp.now())
});
ルール
match /databases/{database}/documents {

   match /users/{userId} {
      // 以下を追加
      allow update: if request.auth != null && userId == request.auth.uid
                    && request.resource.data.keys().hasAll(['age', 'attendanceTimes', 'name'])
                    && request.resource.data.keys().size() == 3
                    && request.resource.data.age == resource.data.age
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.attendanceTimes.hasAll(resource.data.attendanceTimes)   // 元々の配列の要素が削除されていない
                    && request.resource.data.attendanceTimes.size() == resource.data.attendanceTimes.size() + 1   // 変更前後で増加した数は1つだけ
   }

}

###ケース解説
クエリについては、Firestoreでは、「arrayUnion」という便利な関数が使える。
また、Timestamp.now()のようにして、Firebase側の現在時刻を設定することができる。
よく使用するものは下記に記載がある。

ルールについては、前回のやり方と同じようにして、今回は配列が1つだけ追加されるときのみアクセスを許可するルールを設定している。

便利なクエリちらほら

トランザクション処理

const user = firebase.auth().currentUser;
const userDoc = firebase.firestore().collection('users').doc(user.uid);

// 以下の書き方
await firebase.firestore().runTransaction(async transaction => {
    const userStock = (await transaction.get(userDoc)).data().stock;
    transaction.update(userDoc, { stock: userStock +1 });
  });

トランザクション処理では最初にget(データ取得)の処理を記載してから、set,update,delete(データ更新系)の処理を記載する。
順番を間違えると、エラーとなる。

リアルタイムリスナー

const user = firebase.auth().currentUser;

firebase.firestore().collection("users").doc(user.uid)
    .onSnapshot((doc) => {
      // この中にデータが更新されたときにする処理を記述
    });

他ユーザのイベントによるデータの変化をリアルタイムでフロントエンドに反映させたいとき、上記のような書き方でいける。
チャットなどのリアルタイム性が必要な機能に良い。

多層where句

const datas = firebase.firestore().collection('pets').where('type', '==', 'cat').where('color', '==', 'blue');

where句は重ねられる。

最後に

下記は以前私が書いた記事ですが、Vue.js + Firebase を使って実装したものです。
この実装過程でFirebaseについて学習しました。

今回貼り付けたソースコードは、実際に動く私のソースコードを記事用に編集したものです。
もし、間違いや「動かないよ!」ということがあれば、コメントを頂ければ幸いでございます。
また、誤ったことを書いておりましたら、ご指摘いただければ幸いです。

最後までお読みいただき、ありがとうございました。

23
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?