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

1. 始める前に

Cloud Firestore、Cloud Storage for Firebase、Realtime Database は、読み取りおよび書き込みアクセスを許可するために作成された構成ファイルに依存します。セキュリティ ルールと呼ばれるその構成は、アプリの一種のスキーマとしても機能します。これはアプリケーション開発の最も重要な部分の 1 つです。このコードラボでは、それについて説明します。

前提条件

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

何をしますか

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

次の方法を学びます。

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

2.セットアップ

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

ブログ投稿の下書き:

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

公開されたブログ投稿:

  • 公開された投稿はユーザーが作成することはできず、関数を介してのみ作成できます。
  • これらは、 visible属性を false に更新するソフト削除のみ可能です。

コメント

  • 公開された投稿では、コメントが許可されます。コメントは、公開された各投稿のサブコレクションです。
  • 悪用を減らすために、ユーザーはコメントを残すために認証済みの電子メール アドレスを持っている必要があり、拒否主義者ではない必要があります。
  • コメントは投稿後 1 時間以内にのみ更新できます。
  • コメントは、コメント作成者、元の投稿の作成者、またはモデレータによって削除できます。

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

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

ソースコードを取得する

このコードラボでは、セキュリティ ルールのテストから始めますが、セキュリティ ルール自体は最小限であるため、最初に行う必要があるのは、テストを実行するソースのクローンを作成することです。

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

次に、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 が最新バージョンであることを確認します。このコードラボはバージョン 8.4.0 以降で動作するはずですが、それ以降のバージョンにはさらに多くのバグ修正が含まれています。

$ firebase --version
9.10.2

3. テストを実行する

このセクションでは、テストをローカルで実行します。これは、Emulator Suite を起動する時期が来たことを意味します。

エミュレータを起動する

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

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

コマンド ラインで、emulators 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}に置き換えます。 (ドキュメントの構造に関するコメントはルールで役立つ可能性があり、このコードラボに含まれます。コメントは常にオプションです。)

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

次に、ドキュメントは、 authorUIDcreatedAt 、およびtitle 3 つの必須フィールドが含まれている場合にのみ作成できます。 (ユーザーは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 文字以下にする必要があります。

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. 公開された投稿の読み取り、作成、削除: さまざまなアクセス パターンに合わせた非正規化

公開された投稿とドラフト投稿のアクセス パターンは大きく異なるため、このアプリは投稿を非正規化し、別々のdraftpublishedコレクションに分けます。たとえば、公開された投稿は誰でも読むことができますが、完全に削除することはできません。一方、下書きは削除できますが、作成者とモデレーターのみが読むことができます。このアプリでは、ユーザーが下書きのブログ投稿を公開したい場合、新しい公開済み投稿を作成する関数がトリガーされます。

次に、公開された投稿のルールを作成します。作成するための最も簡単なルールは、公開された投稿は誰でも読むことができ、誰も作成または削除できないということです。次のルールを追加します。

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

検証の追加

公開された投稿の一部のフィールドは変更すべきではありません。具体的には、 urlauthorUID 、およびpublishedAtフィールドは不変です。他の 2 つのフィールド、 titlecontent 、およびvisible更新後も存在する必要があります。条件を追加して、公開された投稿の更新に次の要件を適用します。

// 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 文字未満である必要があり、また
  • 禁止されたユーザーのリストにこれらのユーザーを含めることはできません。禁止されたユーザーのリストは、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. 次のステップ

おめでとう!すべてのテストに合格し、アプリケーションを保護するセキュリティ ルールを作成しました。

次に詳しく説明する関連トピックをいくつか示します。

  • ブログ投稿: セキュリティ ルールをコード レビューする方法
  • コードラボ: エミュレータを使用したローカルでの最初の開発を説明する
  • ビデオ: GitHub Actions を使用してエミュレータベースのテストに CI をセットアップする方法