Firebase セキュリティ ルールで Firestore データを保護する

1. 始める前に

Cloud Firestore、Cloud Storage for Firebase、Realtime Database は、読み取りと書き込みのアクセス権を付与するために、作成した構成ファイルを使用します。この構成はセキュリティ ルールと呼ばれ、アプリのスキーマの一種としても機能します。これは、アプリケーション開発において最も重要な部分の一つです。この 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 Functions がトリガーされ、published コレクションに新しいドキュメントが作成されます。
  • ドラフトは、投稿者またはサイト管理者が削除できます

公開済みのブログ投稿:

  • 公開済み投稿はユーザーが作成することはできず、関数でのみ作成できます。
  • 削除(復元可能)のみが可能で、この操作により visible 属性が false に更新されます。

コメント

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

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

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

ソースコードを取得する

この Codelab では、まずセキュリティ ルールのテストを行います。ただし、セキュリティ ルール自体は最小限に抑えます。まず、テストを実行するためにソースをクローン化する必要があります。

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

次に、この Codelab の残りの作業を行う initial-state ディレクトリに移動します。

$ cd codelab-rules/initial-state

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

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

Firebase CLI を取得する

テストの実行に使用するエミュレータ スイートは 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

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

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

ブログ投稿を作成する際の最後の要件は、タイトルの長さが 50 文字以下であることです。

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

これらの条件をすべて満たす必要があるため、これらの条件を 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 つ目の要件は、2 つの属性(authorUIDcreatedAt)を変更しないことです。

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

最後に、タイトルは 50 文字以内にする必要があります。

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 という新しい関数を作成します。この関数は、投稿ドキュメント(下書きと公開済みの投稿のどちらでも機能します)とユーザーの 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 つの条件を満たす必要があります。

  • 認証には認証された E メールが必要です
  • コメントは 500 文字未満にする必要があります。
  • Firestore の bannedUsers コレクションに保存される禁止ユーザーのリストには追加できません。これらの条件を 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 を設定する方法