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 Functions がトリガーされ、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

次に、ドキュメントは、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 つ目の要件は、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 という新しい関数を作成します。この関数は、投稿ドキュメント(下書きと公開済みの投稿のどちらでも機能します)とユーザーの 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 をセットアップする方法