Firebase セキュリティ ルールを使用して Firestore データを保護する

1. 始める前に

Cloud Firestore、Cloud Storage for Firebase、Realtime Database は、ユーザーが書き込みを行った構成ファイルに依存して読み取りと書き込みのアクセス権を付与します。セキュリティ ルールと呼ばれるこの構成は、アプリのスキーマのような役割を果たすこともできます。これは、アプリケーション開発において最も重要な部分の 1 つです。この Codelab では、その手順について説明します。

前提とする環境

  • Visual Studio Code、Atom、Sublime Text などのシンプルなエディタ
  • Node.js 8.6.0 以降(Node.js をインストールするには、nvm を使用します。バージョンを確認するには、node --version を実行します)
  • Java 7 以降(Java をインストールするには、こちらの手順を使用します。お使いのバージョンを確認するには、java -version を実行します)

演習内容

この Codelab では、Firestore で構築されたシンプルなブログ プラットフォームを保護します。Firestore エミュレータを使用してセキュリティ ルールに対して単体テストを実行し、想定どおりのアクセスがルールによって許可または禁止されていることを確認します。

次の方法を学習します。

  • きめ細かい権限を付与する
  • データと型の検証を適用する
  • 属性ベースのアクセス制御を実装する
  • 認証方法に基づいてアクセス権を付与する
  • カスタム関数を作成する
  • 時間ベースのセキュリティ ルールを作成する
  • 拒否リストと削除(復元可能)を実装する
  • 複数のアクセス パターンを満たすためにデータを非正規化するタイミングを理解する

2. 設定

これはブログ アプリケーションです。アプリケーションの機能の概要は次のとおりです。

ブログ投稿の下書き:

  • ユーザーは下書きのブログ投稿を作成できます。作成した投稿は drafts コレクションにあります。
  • 公開準備が整うまで、作成者は下書きの更新を続けることができます。
  • 公開する準備ができたら、Firebase 関数がトリガーされ、published コレクションに新しいドキュメントを作成します。
  • 下書きは作成者またはサイトのモデレーターが削除できます

公開されているブログ投稿:

  • 公開済みの投稿は、ユーザーが作成することはできません。作成するには関数を使用する必要があります。
  • 削除(復元可能)のみ可能です。その場合、visible 属性は false に更新されます。

コメント

  • 公開済みの投稿ではコメントが許可されています。コメントとは、公開された各投稿のサブコレクションです。
  • 不正行為を防止するため、ユーザーは確認済みのメールアドレスを持っている必要があります。また、コメントを投稿する際に拒否リストに登録されていない必要があります。
  • コメントは投稿後 1 時間以内のみ更新できます。
  • コメントは、コメントの投稿者、元の投稿の作成者、またはモデレーターが削除できます。

アクセスルールに加えて、必須フィールドとデータ検証を適用するセキュリティ ルールを作成します。

Firebase Emulator Suite を使用して、すべてがローカルで行われます。

ソースコードを取得する

この Codelab では、セキュリティ ルールのテストから始めますが、セキュリティ ルール自体は擬似的なものであるため、まず、テストを実行するソースのクローンを作成する必要があります。

$ git clone https://github.com/FirebaseExtended/codelab-rules.git

次に、初期状態のディレクトリに移動します。このディレクトリは、この Codelab の残りの部分で作業します。

$ cd codelab-rules/initial-state

次に、テストを実行できるように依存関係をインストールします。インターネットの接続速度が遅い場合は、1 ~ 2 分かかることがあります。

# Move into the functions directory, install dependencies, jump out.
$ cd functions && npm install && cd -

Firebase CLI を取得する

テストの実行に使用する Emulator Suite は、次のコマンドを使用してマシンにインストールできる Firebase CLI(コマンドライン インターフェース)の一部です。

$ npm install -g firebase-tools

次に、CLI が最新バージョンであることを確認します。この Codelab はバージョン 8.4.0 以降で動作しますが、以降のバージョンにはより多くのバグ修正が含まれています。

$ firebase --version
9.10.2

3. テストを実行する

このセクションでは、ローカルでテストを実行します。いよいよ Emulator Suite を起動します。

エミュレータを起動する

操作するアプリケーションには、3 つの主要な Firestore コレクションがあります。drafts には進行中のブログ投稿が含まれ、published コレクションには公開済みのブログ投稿が含まれ、comments は公開された投稿のサブコレクションです。このリポジトリには、セキュリティ ルールの単体テストが付属しています。この単体テストでは、draftspublishedcomments コレクションでユーザーがドキュメントを作成、読み取り、更新、削除するために必要なユーザー属性とその他の条件を定義します。これらのテストに合格するようにセキュリティ ルールを作成します。

まず、データベースはロックダウンされています。データベースへの読み取りと書き込みは普遍的に拒否され、すべてのテストが失敗します。セキュリティ ルールを作成すると、テストに合格します。テストを表示するには、エディタで functions/test.js を開きます。

コマンドラインで、emulators:exec を使用してエミュレータを起動し、テストを実行します。

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"

出力の先頭までスクロールします。

$ firebase emulators:exec --project=codelab --import=.seed "cd functions; npm test"
i  emulators: Starting emulators: functions, firestore, hosting
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: auth, database, pubsub
⚠  functions: Unable to fetch project Admin SDK configuration, Admin SDK behavior in Cloud Functions emulator may be incorrect.
i  firestore: Importing data from /Users/user/src/firebase/rules-codelab/initial-state/.seed/firestore_export/firestore_export.overall_export_metadata
i  firestore: Firestore Emulator logging to firestore-debug.log
⚠  hosting: Authentication error when trying to fetch your current web app configuration, have you run firebase login?
⚠  hosting: Could not fetch web app configuration and there is no cached configuration on this machine. Check your internet connection and make sure you are authenticated. To continue, you must call firebase.initializeApp({...}) in your code before using Firebase.
i  hosting: Serving hosting files from: public
✔  hosting: Local server: http://localhost:5000
i  functions: Watching "/Users/user/src/firebase/rules-codelab/initial-state/functions" for Cloud Functions...
✔  functions[publishPost]: http function initialized (http://localhost:5001/codelab/us-central1/publishPost).
✔  functions[softDelete]: http function initialized (http://localhost:5001/codelab/us-central1/softDelete).
i  Running script: pushd functions; npm test
~/src/firebase/rules-codelab/initial-state/functions ~/src/firebase/rules-codelab/initial-state

> functions@ test /Users/user/src/firebase/rules-codelab/initial-state/functions
> mocha

(node:76619) ExperimentalWarning: Conditional exports is an experimental feature. This feature could change at any time


  Draft blog posts
    1) can be created with required fields by the author
    2) can be updated by author if immutable fields are unchanged
    3) can be read by the author and moderator

  Published blog posts
    4) can be read by everyone; created or deleted by no one
    5) can be updated by author or moderator

  Comments on published blog posts
    6) can be read by anyone with a permanent account
    7) can be created if email is verfied and not blocked
    8) can be updated by author for 1 hour after creation
    9) can be deleted by an author or moderator


  0 passing (848ms)
  9 failing

...

現在、エラーは 9 件です。ルールファイルを作成しながら、より多くのテストに合格するのを確認することで、進行状況を測定できます。

4. ブログ投稿の下書きを作成します。

下書きのブログ投稿へのアクセス権は、公開済みのブログ投稿へのアクセス権とは大きく異なるため、このブログアプリでは、下書きのブログ投稿を別のコレクション /drafts に保存します。下書きにアクセスできるのは作成者かモデレーターのみです。下書きには必須フィールドと不変フィールドの検証があります。

firestore.rules ファイルを開くと、デフォルトのルールファイルがあります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

match ステートメント match /{document=**} は、** 構文を使用して、サブコレクション内のすべてのドキュメントに再帰的に適用します。これはトップレベルであるため、現在、誰がリクエストを行っているか、どのようなデータの読み取りまたは書き込みを試みているかに関係なく、同じ包括的なルールがすべてのリクエストに適用されます。

まず、最も内側の match ステートメントを削除して match /drafts/{draftID} に置き換えます。(ドキュメントの構造に関するコメントは、ルールで役立ちます。この Codelab で使用します。コメントは省略可能です)。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional
    }
  }
}

下書き用に最初に記述するルールは、ドキュメントを作成できるユーザーを制御します。このアプリケーションでは、下書きを作成できるのは作成者としてリストされているユーザーのみです。リクエストを行っているユーザーの UID が、書類に記載されている UID と同じであることを確認します。

作成の最初の条件は次のようになります。

request.resource.data.authorUID == request.auth.uid

次に、ドキュメントを作成できるのは、3 つの必須フィールド(authorUIDcreatedAttitle)が含まれている場合のみです。(ユーザーが createdAt フィールドを指定していないため、ドキュメントを作成する前にアプリが追加する必要があることが強制されます)。確認する必要があるのは属性が作成されていることだけなので、request.resource にすべてのキーがあることを確認できます。

request.resource.data.keys().hasAll([
  "authorUID",
  "createdAt",
  "title"
])

ブログ投稿を作成するための最後の要件は、タイトルを 50 文字以下にすることです。

request.resource.data.title.size() < 50

これらの条件がすべて true である必要があるため、これらを論理 AND 演算子 && で連結します。最初のルールは次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

ターミナルでテストを再実行し、最初のテストに合格することを確認します。

5. ブログ投稿の下書きを更新します。

次に、作成者はブログ投稿の下書きを推敲するときに、下書きのドキュメントを編集します。投稿の更新を許可する条件のルールを作成します。まず、下書きを更新できるのは作成者のみです。ここでは、すでに書き込まれている UID を確認します。resource.data.authorUID

resource.data.authorUID == request.auth.uid

更新の 2 つ目の要件は、authorUIDcreatedAt の 2 つの属性は変更しないことです。

request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
]);

最後に、タイトルは半角 50 文字(全角 25 文字)以内にします。

request.resource.data.title.size() < 50;

これらの条件をすべて満たす必要があるため、&& で連結します。

allow update: if
  // User is the author, and
  resource.data.authorUID == request.auth.uid &&
  // `authorUID` and `createdAt` are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "createdAt"
  ]) &&
  // Title must be < 50 characters long
  request.resource.data.title.size() < 50;

完全なルールは次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;
    }
  }
}

テストを再実行し、別のテストに合格することを確認します。

6. 下書きの削除と読み取り: 属性ベースのアクセス制御

作成者は下書きを作成、更新できるのと同様に、下書きを削除することもできます。

resource.data.authorUID == request.auth.uid

また、認証トークンに isModerator 属性が指定されている作成者は、下書きを削除できます。

request.auth.token.isModerator == true

削除にはどちらの条件でも十分であるため、論理 OR 演算子 || で連結します。

allow delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

読み取りにも同じ条件が適用されるため、ルールに権限を追加できます。

allow read, delete: if resource.data.authorUID == request.auth.uid || request.auth.token.isModerator == true

完全なルールは次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }
  }
}

テストを再実行し、別のテストに合格することを確認します。

7. 公開された投稿の読み取り、作成、削除: さまざまなアクセス パターンに対する非正規化

公開された投稿と下書きの投稿のアクセス パターンは非常に異なるため、このアプリは投稿を別々の draft コレクションと published コレクションに非正規化します。たとえば、公開済みの投稿は誰でも読むことができますが、削除(復元不可)することはできません。一方、下書きは削除できますが、閲覧できるのは投稿者とモデレーターのみです。このアプリでは、ユーザーが下書きのブログ投稿を公開しようとすると、公開された新しい投稿を作成する関数がトリガーされます。

次に、公開した投稿に関するルールを記述します。最もシンプルなルールとして、公開された投稿は誰でも閲覧でき、作成や削除はできないというものです。以下のルールを追加します。

match /published/{postID} {
  // `authorUID`: string, required
  // `content`: string, required
  // `publishedAt`: timestamp, required
  // `title`: string, < 50 characters, required
  // `url`: string, required
  // `visible`: boolean, required

  // Can be read by everyone
  allow read: if true;

  // Published posts are created only via functions, never by users
  // No hard deletes; soft deletes update `visible` field.
  allow create, delete: if false;
}

これらを既存のルールに追加すると、ルールファイル全体は次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
        ]) &&
        // Title must be < 50 characters long
        request.resource.data.title.size() < 50;

      allow read, delete: if
        // User is draft author
        resource.data.authorUID == request.auth.uid ||
        // User is a moderator
        request.auth.token.isModerator == true;
    }

    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;
    }
  }
}

テストを再実行し、別のテストに合格することを確認します。

8. 公開された投稿の更新: カスタム関数とローカル変数

公開済みの投稿を更新するための条件は次のとおりです。

  • 作成者かモデレーターだけが行うことができる。
  • 必須項目をすべて含める必要があります。

作成者またはモデレーターになる条件をすでに記述しているため、条件をコピーして貼り付けることもできますが、時間の経過とともに読みやすく、維持するのが難しくなる可能性があります。代わりに、作成者またはモデレーターになるロジックをカプセル化するカスタム関数を作成します。次に、複数の条件から呼び出します。

カスタム関数を作成する

下書きの match ステートメントの上に、isAuthorOrModerator という新しい関数を作成します。この関数は、POST ドキュメント(下書きと公開済みの投稿のどちらでも使用可能)とユーザーの auth オブジェクトを引数として受け取ります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {

    }

    match /drafts/{postID} {
      allow create: ...
      allow update: ...
      ...
    }

    match /published/{postID} {
      allow read: ...
      allow create, delete: ...
    }
  }
}

ローカル変数を使用する

この関数内で、let キーワードを使用して isAuthor 変数と isModerator 変数を設定します。すべての関数は return 文で終わる必要があり、いずれかの変数が true かどうかを示すブール値を返します。

function isAuthorOrModerator(post, auth) {
  let isAuthor = auth.uid == post.authorUID;
  let isModerator = auth.token.isModerator == true;
  return isAuthor || isModerator;
}

関数を呼び出す

次に、この関数を呼び出すように下書きのルールを更新します。その際、最初の引数として resource.data を慎重に渡します。

  // Draft blog posts
  match /drafts/{draftID} {
    ...
    // Can be deleted by author or moderator
    allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
  }

ここで、新しい関数を使用して、公開済みの投稿を更新する条件を記述できます。

allow update: if isAuthorOrModerator(resource.data, request.auth);

検証を追加する

公開済みの投稿には変更できないフィールドがあります。具体的には、urlauthorUIDpublishedAt のフィールドは変更できません。他の 2 つのフィールド(titlecontentvisible)は更新後も存在している必要があります。公開済みの投稿の更新に次の要件を適用する条件を追加します。

// Immutable fields are unchanged
request.resource.data.diff(resource.data).unchangedKeys().hasAll([
  "authorUID",
  "publishedAt",
  "url"
]) &&
// Required fields are present
request.resource.data.keys().hasAll([
  "content",
  "title",
  "visible"
])

カスタム関数を独自に作成する

最後に、タイトルが 50 文字以下であるという条件を追加します。これはロジックの再利用のため、新しい関数 titleIsUnder50Chars を作成することで実行できます。新しい関数を使用すると、公開された投稿を更新するための条件は次のようになります。

allow update: if
  isAuthorOrModerator(resource.data, request.auth) &&
  // Immutable fields are unchanged
  request.resource.data.diff(resource.data).unchangedKeys().hasAll([
    "authorUID",
    "publishedAt",
    "url"
  ]) &&
  // Required fields are present
  request.resource.data.keys().hasAll([
    "content",
    "title",
    "visible"
  ]) &&
  titleIsUnder50Chars(request.resource.data);

完全なルールファイルは次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User creating document is draft author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and url fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is the author, and
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }
  }
}

テストを再実行します。この時点で、合格したテストが 5 件、不合格だったテストが 4 件あるはずです。

9. コメント: サブコレクションとログイン プロバイダの権限

公開された投稿にはコメントが許可されています。コメントは、公開された投稿のサブコレクション(/published/{postID}/comments/{commentID})に保存されます。デフォルトでは、コレクションのルールはサブコレクションには適用されません。公開した投稿の親ドキュメントに適用されるルールと同じものをコメントに適用するのではなく、異なるルールを作成します。

コメントへのアクセスルールを作成するには、match ステートメントから始めます。

match /published/{postID}/comments/{commentID} {
  // `authorUID`: string, required
  // `comment`: string, < 500 characters, required
  // `createdAt`: timestamp, required
  // `editedAt`: timestamp, optional

コメントの閲覧: 匿名にすることはできません

このアプリでは、匿名アカウントではなく、永続的なアカウントを作成したユーザーのみがコメントを閲覧できます。このルールを適用するには、各 auth.token オブジェクトの sign_in_provider 属性を検索します。

allow read: if request.auth.token.firebase.sign_in_provider != "anonymous";

テストを再実行し、もう 1 つテストに合格することを確認します。

コメントの作成: 拒否リストの確認

コメントの作成には 3 つの条件があります。

  • ユーザーには確認済みのメールアドレスが必要です
  • コメントは 500 文字未満にする必要があります。
  • bannedUsers コレクションの Firestore に保存されている参加禁止ユーザーのリストに含めることはできません。以下の条件を 1 つずつ検討します。
request.auth.token.email_verified == true
request.resource.data.comment.size() < 500
!exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

コメントを作成する際の最終的なルールは次のとおりです。

allow create: if
  // User has verified email
  (request.auth.token.email_verified == true) &&
  // UID is not on bannedUsers list
  !(exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

ルールファイル全体は次のようになります。

For bottom of step 9
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));
    }
  }
}

テストを再実行し、もう 1 つテストに合格することを確認します。

10. コメントの更新: 時間ベースのルール

コメントのビジネス ロジックでは、コメント作成者は作成後 1 時間はコメントを編集できます。これを実装するには、createdAt タイムスタンプを使用します。

まず、ユーザーが作成者であることを確認します。

request.auth.uid == resource.data.authorUID

次に、コメントが過去 1 時間以内に作成されたことを確認します。

(request.time - resource.data.createdAt) < duration.value(1, 'h');

これらを論理 AND 演算子と組み合わせると、コメントを更新するルールは次のようになります。

allow update: if
  // is author
  request.auth.uid == resource.data.authorUID &&
  // within an hour of comment creation
  (request.time - resource.data.createdAt) < duration.value(1, 'h');

テストを再実行し、もう 1 つテストに合格することを確認します。

11. コメントの削除: 親の所有権の確認

コメントは、コメント投稿者、コメント モデレーター、ブログ投稿の投稿者が削除できます。

まず、先ほど追加したヘルパー関数で、投稿またはコメントに存在する可能性がある authorUID フィールドを確認するため、ヘルパー関数を再利用して、ユーザーが作成者かモデレーターかを確認できます。

isAuthorOrModerator(resource.data, request.auth)

ユーザーがブログ投稿作成者かどうかを確認するには、get を使用して Firestore で投稿を検索します。

request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID

次のいずれかの条件で十分なため、条件間に論理 OR 演算子を使用します。

allow delete: if
  // is comment author or moderator
  isAuthorOrModerator(resource.data, request.auth) ||
  // is blog post author
  request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;

テストを再実行し、もう 1 つテストに合格することを確認します。

ルールファイル全体は次のようになります。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Returns true if user is post author or a moderator
    function isAuthorOrModerator(post, auth) {
      let isAuthor = auth.uid == post.authorUID;
      let isModerator = auth.token.isModerator == true;
      return isAuthor || isModerator;
    }

    function titleIsUnder50Chars(post) {
      return post.title.size() < 50;
    }

    // Draft blog posts
    match /drafts/{draftID} {
      // `authorUID`: string, required
      // `content`: string, optional
      // `createdAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, optional

      allow create: if
        // User is author
        request.auth.uid == request.resource.data.authorUID &&
        // Must include title, author, and createdAt fields
        request.resource.data.keys().hasAll([
          "authorUID",
          "createdAt",
          "title"
        ]) &&
        titleIsUnder50Chars(request.resource.data);

      allow update: if
        // User is author
        resource.data.authorUID == request.auth.uid &&
        // `authorUID` and `createdAt` are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "createdAt"
          ]) &&
        titleIsUnder50Chars(request.resource.data);

      // Can be read or deleted by author or moderator
      allow read, delete: if isAuthorOrModerator(resource.data, request.auth);
    }

    // Published blog posts are denormalized from drafts
    match /published/{postID} {
      // `authorUID`: string, required
      // `content`: string, required
      // `publishedAt`: timestamp, required
      // `title`: string, < 50 characters, required
      // `url`: string, required
      // `visible`: boolean, required

      // Can be read by everyone
      allow read: if true;

      // Published posts are created only via functions, never by users
      // No hard deletes; soft deletes update `visible` field.
      allow create, delete: if false;

      allow update: if
        isAuthorOrModerator(resource.data, request.auth) &&
        // Immutable fields are unchanged
        request.resource.data.diff(resource.data).unchangedKeys().hasAll([
          "authorUID",
          "publishedAt",
          "url"
        ]) &&
        // Required fields are present
        request.resource.data.keys().hasAll([
          "content",
          "title",
          "visible"
        ]) &&
        titleIsUnder50Chars(request.resource.data);
    }

    match /published/{postID}/comments/{commentID} {
      // `authorUID`: string, required
      // `createdAt`: timestamp, required
      // `editedAt`: timestamp, optional
      // `comment`: string, < 500 characters, required

      // Must have permanent account to read comments
      allow read: if !(request.auth.token.firebase.sign_in_provider == "anonymous");

      allow create: if
        // User has verified email
        request.auth.token.email_verified == true &&
        // Comment is under 500 characters
        request.resource.data.comment.size() < 500 &&
        // UID is not on the block list
        !exists(/databases/$(database)/documents/bannedUsers/$(request.auth.uid));

      allow update: if
        // is author
        request.auth.uid == resource.data.authorUID &&
        // within an hour of comment creation
        (request.time - resource.data.createdAt) < duration.value(1, 'h');

      allow delete: if
        // is comment author or moderator
        isAuthorOrModerator(resource.data, request.auth) ||
        // is blog post author
        request.auth.uid == get(/databases/$(database)/documents/published/$(postID)).data.authorUID;
    }
  }
}

12. 次のステップ

これで完了です。すべてのテストに合格し、アプリケーションを保護するセキュリティ ルールを記述しました。

以下に、関連するトピックをいくつかご紹介します。

  • ブログ投稿: セキュリティ ルールをコーディングする方法
  • Codelab: エミュレータを使用したローカル ファーストの開発手順
  • 動画: GitHub Actions を使用してエミュレータ ベースのテストに CI をセットアップする方法