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 の以降の作業を行います。

$ 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 つ目の要件は、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 という新しい関数を作成します。この関数は、投稿ドキュメント(下書きまたは公開済みの投稿のいずれか)とユーザーの認証オブジェクトを引数として受け取ります。

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";

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

コメントを作成する: 拒否リストを確認する

コメントを作成するには、次の 3 つの条件を満たす必要があります。

  • 認証には認証された E メールが必要です
  • コメントは 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 をセットアップする方法