[この記事は Todd Kerpelman、デベロッパー アドボケートによる The Firebase Blog の記事 "Group Security in the Firebase Database" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。]
Todd Kerpleman


Todd Kerpelman
Developer Advocate
一部のグループの人たちの間でプライベートな会話ができるチャットアプリがあるとしましょう。または、友人たちのグループで一緒にアルバムを作ることができる写真共有アプリでも構いません。こういった種類のデータ共有を小さなグループのユーザーに制限し、世界に向けて公開しないようにするにはどうすればよいでしょうか。

Firebase のセキュリティ ルールが活躍するのはこのような場合です。Firebase のセキュリティ ルールはとても強力ですが、ちょっとしたガイダンスが必要になる場合もあります。それはルールが複雑であるからではなく、ほとんどの人々は十分な知見を得られるほど頻繁にこの機能を使っていないからです。

皆さんはラッキーです。なぜかと言えば、私はこのブログ投稿を書くために、ここ数週間 Firebase セキュリティの専門家の人々の隣にいてずいぶん彼らを悩ませてきたからです。さらに重要なのは、セキュリティ ルールを理解しやすくするある奇策を見つけたことです。これについては、本投稿の最後の部分で皆さんにお話しします。

ここでは、プライベート グループで会話を行うチャットアプリの例について考えてみましょう。チャット グループに参加しているユーザーは全員チャット メッセージを読み書きできますが、その他のユーザーには見られたくありません。

Database の構造は次のようになっているものとします。もちろん、これを実現する方法はたくさんありますが、デモ用の構造として一番簡単なのはおそらくこのようなものでしょう。

準プライベートと言える各チャットの中には、参加できるユーザーの一覧とチャット メッセージの一覧があります(現実の世界では、これらのユーザー ID は user_abc よりははるかに複雑になるでしょう)。

ここで設定したい最初のセキュリティ ルールは、メンバーのリストにあるユーザーのみがチャット メッセージを参照できるようにすることです。これは、いくつかのセキュリティ ルールを使って作成できます。
{
    "rules": {
      "chats": {
        "$chatID": {
          "messages": {
            ".read": "data.parent().child('members').child(auth.uid).exists()"
          }
        }
      }
    }
}

ここで言っているのは、chats//messages 内のチャットは、同じチャットの members セクションに userID が存在する場合に限り読み取りが許可されているということです。
ところで、$chatID という行は気にならないでしょうか。これはワイルドカードと同じく何にでもマッチしますが、マッチした値を後で使えるように $chatID 変数に格納することを示します。

では、user_abc の場合はどうなるでしょうか。チャット メッセージは完全に読み取りが可能です。しかし、user_xyz は読み取りが許可されていません。チャット グループに members/user_xyz というエントリがないからです。

このことがわかれば、同じようにしてメンバーのみがチャット メッセージを書き込めるルールを追加するのも簡単です。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).exists()"
    }
  }
}

お望みであれば、さらに細かく制御することも可能です。このチャットアプリに、参照はできてもメッセージを書き込むことはできない "lurker" というユーザータイプが存在する場合はどうなるでしょうか。


これにも対応することができます。その場合、「owner または chatter のみメッセージの書き込みが可能」ということを示すルールに変更します。これは、次のようなルールになるでしょう。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
    }
  }
}

(簡潔になるように、以降のサンプルでは "rules" 行を省略します。)

ちなみに、「『lurker でないユーザーのみチャット メッセージを書き込めるようにする』方が簡単ではないか」と思われる方もいらっしゃるかもしれません。その場合、当然ながらコードは 1 行少なくて済みます。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).val() != 'lurker'"
    }
  }

しかし、セキュリティはブラックリストに基づくよりもホワイトリストに基づく方がよいものです。考えてみてください。アプリに突然新しいクラスのユーザー(たとえば "newbies")を追加し、このルールの更新を忘れてしまった場合はどうなるでしょうか。

最初のルールでは、新しいグループのユーザーは投稿はできません。しかし、2 番目のルールでは、何でも投稿できるようになってしまいます。どちらの場合も意図とは違うので問題にはなりますが、セキュリティの観点からすれば、後者の方がはるかによくない状況です。

もちろん、ここに記載したことはすべて、あるとても小さな問題を見逃しています。そもそも、ユーザーのリストはどのようにして設定したのでしょうか。

しばらくの間は、ユーザーがアプリから友人のリストを取得できたことにしておきましょう(これは、読者向けの練習問題として残しておきます)。グループチャットに新しいユーザーを追加する際に考えられるオプションはいくつかあります。
  1. チャットのユーザーは誰でも他のユーザーを追加できる
  2. チャットの所有者だけが他のユーザーを追加できる
  3. チャットへの参加申請は誰でもできるが、所有者による承認が必要

いずれの案でも問題ないでしょう。アプリにとってどれが最適なユーザー エクスペリエンスであるかを判断するのはアプリのデベロッパーです。

それでは、1 つずつ順番に見ていきましょう。


チャットのユーザーは誰でも他のユーザーを追加できる

最初のオプションに対応するには、「既にメンバーリストに含まれているユーザーはメンバーリストの書き込みが可能」というルールを設定する必要があります。


メンバーリストに対して、既に設定してある投稿用のルールによく似たものを設定します。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
    },
    "members": {
      ".read": "data.child(auth.uid).exists()",
      ".write": "data.child(auth.uid).exists()"
    }
  }
}

このルールは、基本的に現在のユーザー ID がリスト内にあるユーザーであれば、メンバーリストの読み取りや書き込みができることを示しています。


チャットの所有者だけが他のユーザーを追加できる

所有者だけがリストに書き込めるような制限を加えるのも簡単でしょう。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
    },
    "members": {
      ".read": "data.child(auth.uid).val() == 'owner'",
      ".write": "data.child(auth.uid).val() == 'owner'"
    }
  }
}

これは、「userID が所有者として登録されていれば、チャットの members 要素に書き込みが可能」という意味になります。

これで 2 番目のケースにも対応できます。


チャットへの参加申請は誰でもできるが、所有者による承認が必要

では、ユーザーが参加申請を行い、所有者が承認する場合はどうでしょう。この場合は、Database に members のリストだけでなく、ユーザーが追加できる保留中のリストを含めるとよいでしょう。


グループの所有者は、これらの見込みユーザーをメンバーリストに追加したり、保留中のリストから削除できるようにします。


宣言する最初のルールは、「自分のエントリであれば、保留中リストに追加できる」です。つまり、追加する項目のキーは自分のユーザー ID でなければなりません。

これを追加したルールは次のようになります。

"chats": {
  "$chatID": {
    "messages": {
      ".read": "data.parent().child('members').child(auth.uid).exists()",
      ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
    },
    "members": {
      ".read": "data.child(auth.uid).val() == 'owner'",
      ".write": "data.child(auth.uid).val() == 'owner'"
    },
    "pending": {
      "$uid": {
        ".write": "$uid === auth.uid"
      }
    }
  }
}

ここで言っているのは、「pending/ 以下の要素には、uid が自分の userID であれば書き込みできる」ということです。

さらに厳密にするなら、まだ "pending" リストに追加されていない場合のみ追加可能と指定することもできます。これは次のようになります。
"pending": {
  "$uid": {
    ".write": "$uid === auth.uid && !data.exists()"
  }
}

さらに、既にチャットのメンバーである場合は、自分を追加することができないルールを指定してみましょう。これにはあまり意味がないかもしれませんが、最終的に次のようなルールになります。

"pending": {
  "$uid": {
    ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
  }
}

次に、所有者は pending フォルダ全体を読み書きできるというルールを追加します。
"pending": {
  ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'",
  ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'",
  "$uid": {
    ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
  }
}

これで完成です。チャットの所有者のみメンバーリストを読み書きできるという前のセクションからのルールをそのまま使っていれば、所有者が保留中リストからエントリを削除してメンバーリストに追加できるセキュリティ ルールになります。

最後に 1 つだけ、まだ触れていなかった重要なルールがあります。それは、新しいチャット グループの作成です。チャット グループはどのようにすれば設定できるでしょうか。「メンバーリストが空であれば、誰でもそこに書き込んで自分を所有者とすることができる」ことを宣言すれば、チャット グループを追加することができます。

"members": {
  ".read": "data.child(auth.uid).val() == 'owner'",
  ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')"
}

この書き込みを行うために、まず /chats/chat_345/members にオブジェクト { "user_zzz" : "owner" } を書き込む場合を考えてみてください。newData 行はこのオブジェクトを参照し、ログインユーザー(user_zzz)のキーの子要素が所有者になっていることを確認しています。

その後、所有者は自由にメッセージやユーザーを追加することができます。所有者としてリストに登録されているため、セキュリティ ルールはこのようなアクションを何の問題もなく許可します。

なお、セキュリティ ルールでは、「ディレクトリ作成」の操作は個別の概念として存在しない点に注意してください。ユーザーが chat_456/messages/abc への書き込みを許可されている場合、messages が既に存在するかしないかにかかわらずルールが適用されます(chat_456 についても同じことが言えます)。


セキュリティ ルールの動作を理解する

私は Firebase セキュリティのエキスパートではありませんが、ブログ投稿ではそのふりをすることができます。その秘訣は、ルール シミュレータです。

ルールを変更する際、それを発行する前に Database への読み取りや書き込みをシミュレートしてテストすることができます。Firebase コンソールの Database 配下にある Rules タブの右上には、「SIMULATOR」と書かれるボタンがあります。クリックするとフォームが表示され、そこから任意の種類の読み取り操作や書き込み操作をテストできます。

今回の例では、最後のルールについて、"user_zzz" としてログインしているユーザーで自分自身を所有者として空の /chats/chat_987/members リストに追加する操作をテストしてみます。すると、ルール シミュレータにこの操作は許可されている旨が表示され、書き込みアクションが true と評価された場所がハイライト表示されます。


(厳密に言えば、間違った行がハイライト表示されており、実際に true と評価されるのはルールの 13 行目です。おそらく、ハイライト表示は文字列内の改行を細かく認識していないものと思われます)

一方、このユーザーが空でないリストに自身を所有者として追加しようとすると失敗します。これは想定どおりの動作です。


さらなる改善

さらにいくつかの改善を行うことができます。現在の状況では、所有者は他のメンバーを所有者として追加できるようになっています。実際は、これが望ましい場合もそうでない場合もあるでしょう。

考えてみると、新しいメンバーが適切な役割で追加されているかどうかを検証してはいませんでした。また、UI が扱うことができる長さにチャット メッセージが収まるようにする検証ルールもいくつか追加できるでしょう。この点については、ぜひいろいろ試してみてください。

次に示すのは、最終的なルールです。これをご自分のチャットアプリにコピーして貼り付け、どのように改善できるかを考えてみてください。

{
  "rules": {
    "chats": {
      "$chatID": {
        "messages": {
          ".read": "data.parent().child('members').child(auth.uid).exists()",
          ".write": "data.parent().child('members').child(auth.uid).val() == 'owner' || data.parent().child('members').child(auth.uid).val()=='chatter'"
        },
        "members": {
          ".read": "data.child(auth.uid).val() == 'owner'",
          ".write": "data.child(auth.uid).val() == 'owner' ||(!data.exists()&&newData.child(auth.uid).val()=='owner')"
        },
        "pending": {
          ".read": "data.parent().child('members').child(auth.uid).val() === 'owner'",
          ".write": "data.parent().child('members').child(auth.uid).val() === 'owner'",
          "$uid": {
            ".write": "$uid === auth.uid && !data.exists() && !data.parent().parent().child('members').child($uid).exists()"
          }
        }
      }
    }
  }
}

さらにヘルプが必要な場合は、ぜひドキュメントをご覧いただき、シミュレータでいろいろ試してみてください。シミュレータで Database のセキュリティ ルールを試してみれば、きっと楽しい 1 週間を過ごすことができるでしょう。少なくとも、最初の 3 日は楽しめます。


Posted by Khanh LeViet - Developer Relations Team