攻略!Firestoreのクエリとルール【Firebase】

Firebase

はじめに

以前、Vue.js(フロントエンド) + Firebase(バックエンド)で勤怠管理Webアプリみたいなものを作った。

バックエンドにFirebaseを使用したことで、非常に簡単に短期間で開発することができた。

FirebaseのDBサービスであるFirestoreは、知識がなくてもなんとなく勢いで構築できてしまうほどに簡単である。
しかし適当に構築してしまうと、セキュリティホールとなりやすく、大変危険である。
Firestoreのセキュリティを担保するのが「セキュリティルール」であり、Firestoreを使用する以上、このルールについての知識を持っておくことは必須である。

Firestoreでなぜセキュリティルールが必要か

Firebaseのデータにはクエリを使って、フロントエンドからアクセスすることができる。
今回は「このクエリを通したい場合は、ルールはこのように記載する」というように、通したいクエリとルールの書き方をセットで例示することにより、Firestoreのルールについて攻略しよう。

基本的なルールの書き方

公式サイトが分かりやすい。

Cloud Firestore セキュリティ ルールを構造化する  |  Firebase

ケース①自分のアカウントに紐づくデータの取得を許可する

コレクション「users」のユーザはFirebase Authenticationに登録済みであり、ドキュメントID = Firebase Authenticationのuidとする。
またフロントエンドでは、Firebase Authenticationでユーザ認証済み(ログイン済み)であるとする。

フロントエンドから発行されるクエリの例は以下のようになる。

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

// クエリ発行
const myInfo = await firebase.firestore().collection('users').doc(user.uid).get();

Firestoreセキュリティルールは以下のようになる。

match /databases/{database}/documents {

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

}

3行目 ~ 4行目について以下の通りである。

  • request.auth
    クエリを発行してきたユーザのFirebase Authenticationによる認証情報
    未認証であればNULLとなる
  • userId
    コレクション「users」の任意のドキュメントID

つまり、Firebase Authenticationのuidに合致するようにドキュメントIDを割り当て、ルールでは認証済みユーザのuidとドキュメントIDが合致したもののみ許可するようにすればよい。

ケース②自分と同じグループに属する複数ユーザのデータの取得を許可する

同じ「team」のユーザの情報のみ取得できる仕様としたい。
例えば上図の例であれば、ユーザ : 田中太郎であれば、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);

Firestoreセキュリティルールは以下のようになる。

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;
   }

}

6行目について、以下のようになる。

  • resource.data
    アクセスしようとしているドキュメント(=matchで指定しているドキュメント)内のデータ群を表している。
  • get(/databases/$(database)/documents/users/$(request.auth.uid)).data
    get関数で任意のドキュメントをパス指定して取得できる。
    本式は、コレクション「users」の認証済みユーザのドキュメントのデータ群を表している。

つまり、ドキュメントの「team」がアクセスアカウントと同じ「team」であるかどうかをアクセス許可条件にしてやればよい。

またこのとき、ユーザ : 田中太郎が以下のクエリを発行して、同じ情報を取得することはできない。

// 'red'teamであるuserにクエリ発行
const datas = firebase.firestore().collection('users').where('team', '==', 'red');

ユーザ : 田中太郎は’red’チームなので、ルールである「アクセスアカウントと同じチームである」を満たすためにデータアクセスは許可されるように見える。
しかし、実はこれはルールを満たしていない。
クエリのwhere句の条件は「’red’チームであるもの」であり、一方ルールで指定した条件は「アクセスアカウントと同じチームであるもの」である。
このクエリの例は、チーム名’red’を生値で指定しており、たまたまチーム名が一致しているだけであって、ルールの「アクセスアカウントと同じ」という部分を満たしていない。
クエリとルールについて、最終的な結果が一致していればよいのではなく、どのユーザアカウントのどこのデータがどうであるのか、というルールを厳密に満たしたクエリだけが許可/拒否されるのである。

ケース③アクセス権限があるユーザのみ、特定のデータを取得できるようにする

コレクション「chat」のドキュメントIDは、各チームのteamIdと合致するものとする。
「hasChatAuth」がtrueあるユーザのみ、チャット情報へアクセスする権限がある仕様としたい。

フロントエンドから発行されるクエリの例は以下のようになる。

// 認証情報を取得
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();

Firestoreセキュリティルールは以下のようになる。

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;  // ここで権限チェック 
   }

}

8行目 ~ 9行目は以下のようになる。

  • chatId
    コレクション「chat」の任意のドキュメントID
  • get(/databases/$(database)/documents/users/$(request.auth.uid)).data
    コレクション「users」の認証済みユーザのドキュメントのデータ群を表している。

つまり、chatドキュメントのIDがアクセスアカウントの「teamId」に紐づいており、かつアクセスアカウントの「hasChatAuth」がtrueであるもののみを許可条件とすればよい。

ケース④他ユーザの深い階層に位置するデータの取得を許可する

コレクション「users」のドキュメントは、サブコレクション「history」を持つ。
ケース②でアクセス許可される他ユーザについては、この「history」のデータも取得可能としたい。

フロントエンドから発行されるクエリの例は以下のようになる。

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

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

// 取得したいユーザの名前
const fetchedUserName = "任意任男";

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

Firestoreセキュリティルールは以下のようになる。

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;
      }
   }

}

8行目 ~ 9行目は以下のようになる。

  • historyId
    サブコレクション「history」の任意のドキュメントID
  • get(/databases/$(database)/documents/users/$(userId)).data
    コレクション「users」の「userId」が示すドキュメント内のデータ群を表している。
  • get(/databases/$(database)/documents/users/$(request.auth.uid)).data
    コレクション「users」の認証済みユーザのドキュメントのデータ群を表している。

つまり、サブコレクション「history」のドキュメントのアクセス許可条件として、自分と同じteamかどうかの判定を行えばよい。

ケース⑤新しいドキュメントの新規作成を許可する

認証済みユーザについて、コレクション「users」に自分のドキュメントを新規作成できる仕様としたい。

フロントエンドから発行されるクエリの例は以下のようになる。

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

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

Firestoreセキュリティルールは以下のようになる。

match /databases/{database}/documents {

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

}

3行目 ~ 4行目について以下の通りである。

  • request.auth
    フロントエンドから呼び出してきたユーザのFirebase Authenticationによる認証情報
    未認証であればNULLとなる
  • userId
    コレクション「users」の任意のドキュメントID

ケース①の「read」が「create」に変わったのみである。

ケース⑥他ユーザの特定のデータのみの更新を許可する

同じ「team」のユーザの「evaluation」の値のみ、変更できる仕様としたい。
「evaluation」の値は、’A’か’B’か’C’とする。

フロントエンドから発行されるクエリの例は以下のようになる。

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

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

// 取得したいユーザの名前
const fetchedUserName = "任意任男";

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

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

Firstoreセキュリティルールは以下のようになる。

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"のみ
   }

}

8行目 ~ 15行目は以下のようになる。

  • resource.data
    アクセスしようとしているドキュメント(=matchで指定しているドキュメント)内のデータ群を表している。
  • get(/databases/$(database)/documents/users/$(userId)).data
    コレクション「users」の「userId」が示すドキュメント内のデータ群を表している。
  • request.resource.data
    発行したクエリが成功した場合の、アクセスしようとしているドキュメント内のデータ群を表している。
  • request.resource.data.keys()
    ↑のデータ群の、データkeyの一覧を取得する。
  • ~~~.hasAll(…)
    リスト型データに存在する関数で、引数で指定した全ての値がリストに含まれるかチェックする。
  • ~~~.size()
    リスト型データに存在する関数で、データの個数を取得する関数である。

つまり、以下の条件を全て満たしたときのみ更新可能とすればよい。

  • アクセスアカウントと「team」が同じ
  • クエリが成功した場合、ドキュメントのデータkeyの内容に変化がない
  • クエリが成功した場合、「evalueation」以外の要素に変化がない
  • クエリが成功した場合、「evalueation」の値は’A’か’B’か’C’のいずれかの値である

ルールに隙を作ってしまってはいけない。
ドキュメントの入り口を制御して満足するのではなく、更にその先、意図しない値を更新されないようにしたり、意図しない値を設定できないようにする制御も必要である。

ケース⑦自分のドキュメントの特定のリストデータについて、1クエリにつき1要素のみの追加を許可する

自分のドキュメントの「attendanceTimes」について、1回のクエリで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())
});

Firstoreセキュリティルールは以下のようになる。

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つだけ
   }

}

6行目 ~ 11行目は以下のようになる。

  • resource.data
    アクセスしようとしているドキュメント(=matchで指定しているドキュメント)内のデータ群を表している。
  • get(/databases/$(database)/documents/users/$(userId)).data
    コレクション「users」の「userId」が示すドキュメント内のデータ群を表している。
  • request.resource.data
    発行したクエリが成功した場合の、アクセスしようとしているドキュメント内のデータ群を表している。
  • request.resource.data.keys()
    ↑のデータ群の、データkeyの一覧を取得する。
  • ~~~.hasAll(…)
    リスト型データに存在する関数で、引数で指定した全ての値がリストに含まれるかチェックする。
  • ~~~.size()
    リスト型データに存在する関数で、データの個数を取得する関数である。

つまり、以下の条件を全て満たしたときのみ更新可能とすればよい。

  • アクセスアカウントのものと合致するドキュメントである
  • クエリが成功した場合、ドキュメントのデータkeyの内容に変化がない
  • クエリが成功した場合、「attendanceTimes」以外の要素に変化がない
  • クエリが成功した場合、「attendanceTimes」に新しい値が1つ追加されているのみである

最後に

Firebaseでバックエンド構築するのはホントオススメ。
セキュリティルールの書き方についてしっかりと学び、セキュリティホールのないFirebase環境を構築しよう!

コメント

タイトルとURLをコピーしました