ユーザのデータを他のユーザに共有できるようにする Firestore のルールを書く

自分の github のプライベートレポジトリを、他の特定のユーザにだけ共有できるようにする、みたいなことを firestore を使った web アプリで実現するにはどうするか、という内容です。

- [ユーザーとグループのデータアクセスを保護する ](https://firebase.google.com/docs/firestore/solutions/role-based-access?hl=ja)

基本的には、上の記事とほぼ同じことをしてます。上の記事で説明されてない部分に、注釈を入れて自分的にわかりやすくしたメモのようなものです。

# 前提

- ユーザ ID は firebase auth で自動的に割り当てられたものを使う
- 権限は以下の４種類
    - owner: データの所有者。データの変更、データへのアクセス権の付与、削除ができる。
    - editor: データの編集者。データの編集ができる。
    - reader: データの閲覧者。データを見ることだけができる。
- データを作成したユーザが、自動的にデータの owner になる。

テスト用アプリには firebase v8 + firebaseUI を使ってます。(firebase v9 でも基本は同じはず)

## Firebase Authentication の準備

分かりやすくするために、メールアドレスで認証可能な、４人のユーザをあらかじめ登録しておいて、それぞれのユーザに予め権限を付与してみる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/eddd6691-9c71-e0b2-2530-38fe0b2a7929.png)

予め firebase authentication で上記のようにユーザを作成しておく。Sign-in method でメール/パスワードも有効にしておく。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/836e6cb6-cf76-2679-ed7c-7bf461896e96.png)

## firestore の準備

下記のような権限が付与されたデータを作る。

- one が owner
- two は editor
- three は reader
- four はどれでもない

データ（ドキュメント）は `/data/{dataID}` に置く。dataID には、firestore が自動的に割り当てたランダム文字列が入る。

実際のデータ（ドキュメントの中身）は以下のような感じ。

```.json
{
  context: 'データの中身',
  users: { // 各ユーザの権限
    one: 'owner',
    two: 'editor',
    three: 'reader'
  }
}
```

一応、ゼロからの作成の手順も書いておきます。

Firebase のプロジェクトのコンソールから Cloud Firestore に行って「データ」のタブを押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/4355c9d7-257e-4ab1-281b-98a69a423d07.png)

「＋コレクションを開始」を押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/6be64b38-de1b-4814-6dc0-2eebcce4e995.png)

こんなパネルが出るので、コレクション ID に data と入力します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/bb43662c-08ca-9abd-e1e3-6721a58c4a13.png)

そして、ドキュメント名のところは「自動ID]と書かれた場所を押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/bcb12e0b-6c1e-0d6a-0df3-508da748238f.png)

すると、ランダムな文字列が設定されて、「保存」のボタンが押せるようになるので、この状態で押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/0a253512-84dc-d05a-5c9d-1134e57149b6.png)

保存すると上のような感じになるので、右の「フィールドを追加」を押します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/fc216b36-fabf-fe9d-5ec7-31fe26c1b109.png)

上のようになるように、フィールドに値を追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/aea988ed-2365-46af-a05f-d7c97441b4b9.png)

users のところは、map 型を指定すれば、上のような感じで入力できます。

## users のユーザ名について

上の例では、分かりやすくするために data.users の中の one とか two とか書いてますが、実際に運用するには firebase authentication の one とか two に対応する uid （ランダムな文字列）に置き換える必要があります。（auth.uid はランダム文字列を参照するため）

```
{
  context: 'データの中身',
  users: { // 各ユーザの権限
    rA83XY5u71gdctkdycyaqtnBewF2: 'owner',
    hzyAzvohRweIE0e6thwmMce6The2: 'editor',
    jxD4qpRk8ZafO9R6gw0EUpcL4px2: 'reader'
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/a7cd24bf-715d-25c8-1805-d285013cecad.png)

実際のデータは、上のような感じにする必要があるということです。

## 目標

ここまでの状態で、ひとまず下記のような動作が可能なように、なおかつこれら以外の動作が不可能なように、firebase のルールを記述します。

- one はデータの読み書きができる。他のユーザの権限の変更もできる。
- two はデータの読み書きができる。それ以外はできない。
- three はデータの読み出しができる。それ以外はできない。
- four はデータにアクセスできない。

# ルールを書く

目標を達成できるような、ルールを実際に記述してみます。

## 最終形

- [ユーザーとグループのデータアクセスを保護する ](https://firebase.google.com/docs/firestore/solutions/role-based-access?hl=ja)

上の記事に準拠して書くと、下記のようになります（多分）。

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
        // サインインしていれば true、そうでなければ false
        function isSignedIn() {
          return request.auth != null;
        }

        // auth.uid で指定したユーザの権限の名前を返す。対応する権限名がない場合は null
        function getRole(rsc) {
          return rsc.data.users[request.auth.uid];
        }

        // データ (rsc) にアクセスしようとしているユーザ (auth.uid) が、
        // rsc.users の中に含まれていて、なおかつそのユーザ (auth.uid) に 
        // 割り当てられている権限は array で指定された権限リストの中に含まれていれば true
        // ユーザがサインインしていないか、上気の条件を満たしていなければ false
        function isOneOfRoles(rsc, array) {
          return isSignedIn() && (getRole(rsc) in array);
        }

        function onlyContentChanged() {
          // ユーザの権限の変更を行っていない場合だけ true
          return request.resource.data.users == resource.data.users
            && request.resource.data.keys() == resource.data.keys();
        }

        // owner はデータ (context, users) の無制限の update ができる。
        // editor はデータの context のみ update できる。
        // reader は read しかできない。
        allow update: if isOneOfRoles(resource, ['owner'])
                      || (isOneOfRoles(resource, ['editor']) && onlyContentChanged());
        allow read: if isOneOfRoles(resource, ['owner', 'editor', 'reader']);

      }
   }
}
```

実際には上のように function を使ったほうが見通しは良くなりますが、動作を理解するには分かりにくいです。以下、元ページの記事にあるより、もっと低レベルのところから、権限を順番に追加してみます。

## owner, editor, reader に読み出し権限を付与する

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          allow read: if request.auth != null && (
                      get(/databases/$(database)/documents/data/$(dataID)).data.users[request.auth.uid] == 'owner'||
                      get(/databases/$(database)/documents/data/$(dataID)).data.users[request.auth.uid] == 'editor'||
                      get(/databases/$(database)/documents/data/$(dataID)).data.users[request.auth.uid] == 'reader');
      }
   }
}
```

`request.auth != null` の部分は、サインインしているかをチェックしています。サインインされていなければ、`request.auth` は `null` になります。

`get(/databases/$(database)/documents/data/$(dataID))` とすると、match で指定している `database` と `dataID` の値がそれぞれ `$(database)` と `$(dataID)` に代入されて、そのパスで指定されたフィールドに対応するオブジェクトが読み出されます。

フィールドの値を読むには、取得したオブジェクトの `.data` の値を読む必要があります。`obj.users` ではダメで `obj.data.users` にしないといけないということです（これが結構分からなかった！）。`.data` のあとは、普通に読み出したいプロパティ名を指定すれ良いようです。

`request.auth.uid` には、現在ログイン中のユーザの UID が入ってきます。例えば、上のほうで作ったユーザリストの例でいえば、 `one@hoge.com` でログインしていれば、uid は `rA83XY5u71gdctkdycyaqtnBewF2` になります。

つまり、実際には `users['rA83XY5u71gdctkdycyaqtnBewF2']` という値が読み取られて、それが 'owner' か 'editor' か 'reader' なら true になるという感じです（多分）。 

## get() の部分を resource で書き換える

アプリからアクセスしようとしているドキュメントは、resource という変数で指定できようです。これを使うと、上の長い get のところは下のように書き換えできます。

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          allow read: if request.auth != null && (
                      resource.data.users[request.auth.uid] == 'owner'||
                      resource.data.users[request.auth.uid] == 'editor'||
                      resource.data.users[request.auth.uid] == 'reader');
      }
   }
}
```

`resource` を使う場合でも、フィールドの値を参照したいときは `resource.data.XXXX` のように `.data` が必要です。

基本 get() は、アプリでアクセスしているところとは無関係なドキュメントを指定するときにつかいます（多分）。

## owner に無条件の update 権限を付与する

下記のようにします。ログイン中のユーザが users で owner に指定されている場合のみ、update できるようにします。update は context のデータの変更だけでなく、users のデータも変更できる（つまり、ユーザの権限も変更できる）ということです。

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          allow read: if request.auth != null && 
                      ( resource.data.users[request.auth.uid] == 'owner' ||
                        resource.data.users[request.auth.uid] == 'editor' ||
                        resource.data.users[request.auth.uid] == 'reader' );
                        
          allow update: if request.auth != null && 
                        resource.data.users[request.auth.uid] == 'owner';
      }
   }
}
```
## editor に users 以外の変更権限を付与する

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          allow read: if request.auth != null && 
                      ( resource.data.users[request.auth.uid] == 'owner' ||
                        resource.data.users[request.auth.uid] == 'editor' ||
                        resource.data.users[request.auth.uid] == 'reader' );
                        
          allow update: if request.auth != null && 
                        ( resource.data.users[request.auth.uid] == 'owner' ||
                          ( resource.data.users[request.auth.uid] == 'editor'    &&
                            request.resource.data.users == resource.data.users &&
                            request.resource.data.keys() == resource.data.keys()
                          )
                        );
      }
   }
}
```

editor にも更新権限を追加します。ただし、`users` の中身を変更することを禁止します。ここで `request.resource.data.users` という表記を使うと、firestore に今あるデータではなく、firestore 側に set (更新) しようとしている、クライアント側のデータを指定できます。つまり `request.resource.data.users == resource.data.users` とすることで、書き込もうとしているデータと firestore 側にあるデータが一致しているかを調べることができます。こんな書き方するのね・・・

`request.resource.data.keys()` で、フィールド値お一覧を取得できます。`request.resource.data.keys() == resource.data.keys()` を評価することで、フィールドの追加や削除がないかをチェックできます。この条件は確かに必要なんですけど、自分ではすぐには思いつかない気がしました。

一応、この段階で、すでに目標は達成されています。

以下、元の記事に書かれているルールとの対応についてみていきます。

## サインインのチェックを関数にする

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          function isSignedIn() {
            return request.auth != null;
          }

          allow read: if isSignedIn() && 
                      ( resource.data.users[request.auth.uid] == 'owner' ||
                        resource.data.users[request.auth.uid] == 'editor' ||
                        resource.data.users[request.auth.uid] == 'reader' );
                        
          allow update: if isSignedIn() && 
                        ( resource.data.users[request.auth.uid] == 'owner' ||
                          ( resource.data.users[request.auth.uid] == 'editor'    &&
                            request.resource.data.users == resource.data.users &&
                            request.resource.data.keys() == resource.data.keys()
                          )
                        );
      }
   }
}
```

`function` を使うことで、処理の一部を関数化できます。関数の返り値は true, false 以外に、値や値のリストを返すこともできます。

## ログイン中のユーザがの権限名を返す部分を関数にする

関数 getRole は、データの users に auth.uid が無ければ null になる関数です。

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          function isSignedIn() {
            return request.auth != null;
          }

          function getRole(rsc) {
            return rsc.data.users[request.auth.uid];
          }

          allow read: if isSignedIn() && 
                      ( getRole(resource) == 'owner' ||
                        getRole(resource) == 'editor' ||
                        getRole(resource) == 'reader' );
                        
          allow update: if isSignedIn() && 
                        ( getRole(resource) == 'owner' ||
                          ( getRole(resource) == 'editor'    &&
                            request.resource.data.users == resource.data.users &&
                            request.resource.data.keys() == resource.data.keys()
                          )
                        );
      }
   }
}
```

## ユーザの権限が指定した権限リストの中に含まれてるか調べる部分を関数にする

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          function isSignedIn() {
            return request.auth != null;
          }

          function getRole(rsc) {
            return rsc.data.users[request.auth.uid];
          }

          function isOneOfRoles(rsc, array) {
            return isSignedIn() && (getRole(rsc) in array);
          }

          allow read: if isSignedIn() && 
                         isOneOfRoles(resource,['owner','editor','reader']);

          allow update: if isSignedIn() && 
                        ( isOneOfRoles(resource,['owner']) ||
                          ( isOneOfRoles(resource,['editor']) &&
                            request.resource.data.users == resource.data.users &&
                            request.resource.data.keys() == resource.data.keys()
                          )
                        );
      }
   }
}
```

`getRole(rsc) in array` という書き方で、`array` の各要素 `rsc` を `getRole` 関数で評価した結果の論理和を取ることができるようです。これで、権限の記述がだいぶんすっきりしました。

ここまでのところで、`data` のフィールドの変更チェックをしている部分を関数化していないところ以外は、冒頭のルールと同じになったはずです。

# テスト用プロジェクト

下記の二つを使います。`index.html` でログインして `test.html` で各権限をテストできます。

```index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Firebase Rules Test</title>
    <div id="firebaseui-auth-container"></div>
    
    <script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-firestore.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-auth.js"></script>
    

    <script src="https://www.gstatic.com/firebasejs/ui/6.0.0/firebase-ui-auth.js"></script>
    <link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/6.0.0/firebase-ui-auth.css" />

    <script>

      let mode = true;

      function init(){
        const firebaseConfig = { // 自分のプロジェクト用の apiKey などを設定する
            apiKey: "***",
            authDomain: "***",
            databaseURL: "***",
            projectId: "***",
            storageBucket: "",
            messagingSenderId: "***",
            appId: "***"
        };
          
        firebase.initializeApp(firebaseConfig); // firestore 初期化
        const db = firebase.firestore();              // db 初期化
        const ui = new firebaseui.auth.AuthUI(firebase.auth());

        ui.start('#firebaseui-auth-container', {
          callbacks: {
            signInSuccessWithAuthResult: function(authResult, redirectUrl) {
              console.log( "signIn");
              return true;
            },
            uiShown: function() {
              console.log( "UI" );
            },
          },
          signInOptions: [
            {
              provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
            }
          ],
          signInSuccessUrl: '/test.html',
        });
      }

      init();
    </script>

  </body>
</html>
```

ログインできたら test.html に自動的に飛びます。

```test.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>firestore test</title>
  </head>
  <body>
    <div id="store_sample">
      <div id="email">
      </div>
      <ul>
          <li> データの読み出し:  <span id="check_read"></span> </li>
          <li> データの更新:     <span id="check_update"></span></li>
          <li> アクセス権の変更:  <span id="check_auth"></span></li>
      </ul>
      <input type="button" value="テスト開始" onclick="check();"/>
      <br/>
      <div id="output">
      </div>
    </div>

    <script src="https://www.gstatic.com/firebasejs/8.8.0/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.8.0/firebase-firestore.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-auth.js"></script>

    <script>

    let db = null;
    let currentUser = null;
    let mode = false;
    
    function init(){
        var firebaseConfig = {
            apiKey: "***",
            authDomain: "***",
            databaseURL: "***",
            projectId: "***",
            storageBucket: "",
            messagingSenderId: "***",
            appId: "***"
        };
        
        firebase.initializeApp(firebaseConfig); // firestore 初期化
        db = firebase.firestore();              // db 初期化

        firebase.auth().onAuthStateChanged( user => {
            if ( user != null ){
                console.log( "SignIn" );
                document.getElementById('email').innerText = user.email;
                currentUser = user;
            }else{
                console.log( "failed");
            }
        });
    }

    function check()
    {
        if ( db == null || currentUser == null ) return;
        console.log( currentUser.uid );
        // read check
        const docRef = db.collection("data").doc("f67Tde8y1uIB9tslWvCJ");
        
        function readTest(){
            docRef.get().then((doc) => {
                let data = null;
                if (doc.exists) {
                    document.getElementById('check_read').innerText = "読み込み成功しました";
                    console.log("Document data:", doc.data());
                    data = doc.data();
                } else {
                    document.getElementById('check_read').innerText = "ドキュメンがありません";
                    console.log("No such document!");
                }

                updateTest(data);
            }).catch((error) => {
                document.getElementById('check_read').innerText = "読み込みに失敗しました";
                console.log("Error getting document:", error);
                
                updateTest(null);
            });
        }

        // edit check
        function updateTest(data){
            if ( data == null ){
                data = {};
            }
            data.context = new Date();
            docRef.set(data).then(() => {
                document.getElementById('check_update').innerText = "更新に成功しました";
                console.log("Document successfully written!");
                updateAuthTest(data);
            })
            .catch((error) => {
                document.getElementById('check_update').innerText = "更新に失敗しました";
                console.log("Error writing document: ", error);
                updateAuthTest(data);
            });
        }

        // auth check
        function updateAuthTest(data){
            if ( data == null ){
                data = { users: {}};
            }
            data.context = new Date();
            if ( data.users.hoge == null ){
                data.users.hoge = 'owner';
            }else{
                delete data.users.hoge;
            }
            docRef.set(data).then(() => {
                document.getElementById('check_auth').innerText = "権限の更新に成功しました";
                console.log("Document successfully written!");
            })
            .catch((error) => {
                document.getElementById('check_auth').innerText = "権限の更新に失敗しました";
                console.log("Error writing document: ", error);
            });
        }

        readTest();
    }

    init();

  </script>
  </body>
</html>
```

## 権限のテストの実行

```
$ firebase init hosting
// プロジェクトを指定する
$ firebase serve
```

これで、`http://localhost:5000` にアクセスすると、ログイン画面がでます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/c1baac4d-9d16-bd07-976c-f5e22761771c.png)

`one@hoge.com` でログインします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/16fd6a4e-9325-6ed3-c0b1-b2250bc04d33.png)

パスワードとかは、firebase authentication のコンソールで指定したものを入れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/5588338e-acab-41ed-b62c-f545b2ad05a3.png)

ログインできるとこうなります。「テスト開始」を押すと、各権限のテストをして結果を表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/d9e39d4d-0527-2c47-1a2b-fddbe47bb83d.png)

`one@hoge.com` は `owner` なのですべてについて成功します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/8303ca27-9d92-4484-d40a-a63ce4f025f4.png)

`two@hoge.com` は `editor` なので、権限の更新以外は成功します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/4925ca0c-02d6-6fbe-1c88-ee66fb1c96e3.png)

`three@hoge.com` は `reader` なので、読み出しだけ成功します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/266247/8a80a00c-41bf-afd6-dc3e-113c40185096.png)

`four@hoge.com` は権限が何もないので、すべて失敗します。

問題なく、目標が達成されていることが確認できました。

# read 権限があるとユーザの権限情報が見える問題

このデータ構造とセキュリティルールだと、data.users の中身は reader にも見えてしまいます。uid はランダム文字列なので、誰だかを直接特定はできないにしても、reader に無関係な第三者の uid を読まれるので、リスクはゼロではない気はします。これを気にする場合、権限のリストは、別のドキュメントで管理したほうがいいのかもしれません。

このとき留意する必要があるのは、firestore のデータへのアクセスの権限は、ドキュメント毎にしか設定できないことです）。

```
{
  context: 'データの中身',
  users: { // 各ユーザの権限
    one: 'owner',
    two: 'editor',
    three: 'reader'
  }
}
```

ドキュメントのフィールドの構造が上記のようになっているとき、context にはアクセスできるけど、users にはアクセスできない、というようなルールは（多分）書けません。なので、同じドキュメント内に読み書きしたいデータと権限の情報を両方書いてしまうと、権限の情報だけアクセス制限するみたいなことはできなさそうです。

権限情報を読ませないためには、例えば下記のような感じで、権限情報を別のドキュメントにしておいて、権限情報を含むドキュメントにアクセス制限をかける、とかすればよさそうです。（もっといい方法があったら教えてください）

```
/data/{dataID}/authinfo/users/
{
    one: 'owner',
    two: 'editor',
    three: 'reader',
}
```

ルールは以下のような感じです(多分)

```
rules_version = '2';
service cloud.firestore {
   match /databases/{database}/documents {
      match /data/{dataID} {
          function isSignedIn() {
            return request.auth != null;
          }

          function getRole() {
            return get(/databases/$(database)/documents/data/$(dataID)/authinfo/users).data[request.auth.uid];
          }

          function isOneOfRoles(rsc, array) {
            return isSignedIn() && (getRole() in array);
          }

          allow read: if isSignedIn() && 
                         isOneOfRoles(resource,['owner','editor','reader']);

          allow update: if isSignedIn() && 
                        isOneOfRoles(resource,['owner','editor']);

          match /authinfo/users {
             allow read, update: if isSignedIn() && 
                        isOneOfRoles(resource,['owner']);
          }
      }
   }
}
```

こうすることで、各権限（データお読みだし、書き込み、ユーザ権限の変更）がそれぞれ別々の `allow` 行に分かれて、すっきりした感じになりました。

ちなみに、上のルールでは新規のデータ追加ができません。新規の追加を許可するには、`allow create` 行を追加する必要があります。