Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

Firestoreセキュリティルールの基礎と実践 - セキュアな Firebase活用に向けたアプローチを理解する

f:id:flattsecurity:20210216172658p:plain

こんにちは、株式会社Flatt Security セキュリティエンジニアの梅内(@Sz4rny)です。

本稿では、Cloud Firestore (以下、Firestore) を用いたセキュアなアプリケーション開発を行うためのアプローチについて説明するとともに、そのアプローチを実現するセキュリティルールの記述例を複数取り上げます。

本稿を読むことで、そもそも Firestore とは何か、どのように Firestore に格納するデータの構造を設計、実装すればセキュアな環境を実現しやすいのか、また、Firestore を利用するアプリケーションにおいてどのような脆弱性が埋め込まれやすいのかといったトピックについて理解できるでしょう。

なお、本稿は以前に投稿した記事と共通する部分があります。理解を補強するために、こちらの記事も適宜ご覧ください。

flattsecurity.hatenablog.com

また、Flatt Securityでは開発/運用中のプロダクトにおいて、Firebaseをセキュアに活用できているか診断することも可能です。 Firebase を用いた開発におけるセキュリティ上の懸念事項が気になる場合や、実際に診断について相談したいという場合は、ぜひ下記バナーからお問い合わせください。

はじめに

Firestore の概要

Firestore は、Google によって提供されている mBaaS (mobile Backend as a Service) である Firebase において利用できる NoSQL 型データベースの1つです。1

Firestore はスキーマレスなドキュメント指向のデータベースです。Firestore において管理されるデータのかたまりをドキュメントと呼びます。また、ドキュメントをまとめて格納するための場所のことをコレクションと呼びます。

ドキュメントは複数のキーと値の組み合わせから構成されるデータです。JavaScript におけるオブジェクトや Python における辞書 (dictionary) をイメージしてもらえると分かりやすいかもしれません。ドキュメントの値には文字列や数値、ブール値といった基本的な値に加えて、リストやマップ等のより複雑なデータ構造、タイムスタンプ値、他のドキュメントを参照するためのパスといったように、さまざまな値を格納することができます。

上記の値に加えて、ドキュメントはサブコレクションを持つことができます。サブコレクションには、コレクションと同様に複数のドキュメントをまとめて格納させることができます。なお、ドキュメントはサブコレクションを複数持つことができます。また、サブコレクション内に格納されたドキュメントもまたサブコレクションを持つことができます。

以下に、コレクション、ドキュメント、サブコレクションの概念図を示します。

f:id:flattsecurity:20210216173329p:plain

Firestore を用いたアプリケーションのアーキテクチャ

Firestore の概要は理解できたでしょうか。Firestore は、ドキュメント指向の NoSQL データベースという点では他の類似したデータベースと大きく異なる点はありませんが、1点だけ大きく異なる点があります。それを説明するために、まずは一般的な Web アプリケーションにおけるアーキテクチャを振り返ることにしましょう。

さて、一般的な Web アプリケーションにおいて、永続的データの管理を担うデータベースはアプリケーションサーバの背後に存在します。このように、クライアントがデータベースに直接アクセスせず、アプリケーションサーバを介してデータを操作する形態を3層アーキテクチャと呼びます。

f:id:flattsecurity:20210216173104p:plain

なぜクライアントが直接データベースにアクセスするのではなく、アプリケーションサーバを介したほうが良いのでしょうか。これは、主に以下に挙げる理由によります。

  • データ操作の抽象化: クライアントがデータベース上のデータを操作する際に、クライアント自身がクエリをその場で構築して送信するよりも、事前に定義されたクエリの送信を行う API をアプリケーションサーバ上で公開しておいたほうが抽象度が高く簡潔である。加えて、データベースから取得したデータをアプリケーションサーバ上で適宜処理することで、クライアントが欲している情報に限ってレスポンスすることが可能である。
  • 認証認可のチェック: データベース上のデータを操作する際に、リクエストを送信してきたクライアントが適切な認証情報及び権限を所持しているかをチェックしないと、適切な権限を持たない主体によってデータが不正に操作される恐れがある。

結論から述べると、Firestore は既存の3層アーキテクチャにおいてビジネスロジックの実行を担っていたアプリケーションサーバの層が存在しません。では、Firestore ではどのようにデータ操作の抽象化や認証認可のチェックが実現されているのでしょうか。

具体的に述べると、データ操作は Firebase から提供されるライブラリを用いて、クライアント側のアプリケーションが直接 Firestore にアクセスするためのクエリを構築することで実現します。また、認証認可のチェックは Security Rules (以下、セキュリティルール) という仕組みを活用することで実現します。2

なお、Firestore はスキーマレスであり、かつクライアントがデータを直接操作するため、データベース上に想定外のデータが作成される恐れがあります。この問題を防ぐためにはバリデーション ―― 作成されようとしているデータが事前に定義された仕様や形式に沿ったものであるかどうかのチェック ―― を行う必要があります。Firestore では、このバリデーションもセキュリティルールを用いて実現することができます。

以下に、Firestore を用いたアプリケーションにおけるアーキテクチャの概要図を示します。

f:id:flattsecurity:20210216173428p:plain

Firestore とセキュリティルール

前章では、Firestore の概要を示すとともに、Firestore を活用したアプリケーションのアーキテクチャや従来アプリケーションサーバが行っていた認証認可のチェック等が Firestore でどのように実現されているかといった事項について説明しました。

以下では、まずセキュリティルールを用いてルールを定義する際の原則を示します。その内容をもとに、セキュリティルールの基礎文法やよく利用されるルールの例を説明します。

セキュリティルールの原則

さて、どのようなセキュリティルールを定義すれば実際にセキュアだと言えるのでしょうか。これを考えるため、もしセキュリティルールがなかった場合にどのような脅威が想定されるのかを考えてみることにしましょう。以下に、想定される脅威の例を示します。

  1. 適切な認証情報を持たない主体によるアクセス
  2. 適切な権限を持たない主体によるデータの操作
  3. 事前に想定されたデータスキーマやアプリケーションの仕様に沿っていないデータの作成

これらの脅威から Firestore 上のデータを守るためには、以下に挙げるチェックを行うセキュリティルールを定義すれば良いと考えられます。

  • アクセス元ユーザの認証情報のチェック (1. に対応)
  • アクセス元ユーザのロールとアプリケーションの仕様に基づいた権限のチェック (2. に対応)
  • 作成あるいは更新されようとしているデータのスキーマに関するバリデーションチェック (3. に対応)
  • アプリケーションの仕様に基づいた作成、更新あるいは削除操作における正当性のチェック (3. に対応)

上記に挙げるチェックを行うためにはセキュリティルールの基礎文法を習得しておくことが不可欠です。基礎文法は、以前の記事公式ドキュメントにまとめられていますので、こちらをご覧ください。

次節以降では、上に挙げた4つのチェックを行うルールの実例を紹介します。

認証情報のチェック

概要

適切な認証情報を持たない主体によるアクセスから Firestore を守るために、セキュリティルールにおいて認証情報をチェックする必要があります。公開情報を配信するためだけに利用されているコレクション等、認証情報をチェックする必要がない一部のケースを除いて認証情報のチェックは必須です。

セキュリティルールでは、Firebase Authentication から提供される認証情報を利用することができます。提供された認証情報は request.auth という変数に格納されます。

request.auth はフィールドとして uid 及び token を有します。この内、uidにはリクエスト元ユーザの ID が、token には認証に関するマップ型のデータがそれぞれ格納されています。以下に、request.auth.token が有するフィールドを抜粋して示します。

フィールド名 概要
email リクエスト元ユーザのメールアドレス。
email_verified リクエスト元ユーザのメールアドレスの疎通確認が完了しているかどうか。
firebase.sign_in_provider 認証に利用されたプロバイダ(例: passwordgoogle.comanonymous等)。

チェックの実例

認証情報のチェックにあたっては、まず認証情報そのものが存在するかどうかを確認します。

request.auth != null

また、認証に利用されたプロバイダが想定されているものかどうかを確認するとより安全です。このチェックがないと、例えば、パスワードによる認証しか想定していないアプリケーションにおいて、うっかり匿名認証を有効化してしまっていた場合に想定外のリスクに派生する恐れがあります。

request.auth.token.firebase.sign_in_provider == 'google.com'

メールアドレスを認証に用いる場合は、当該メールアドレスの疎通確認が取れているかどうかを確認します。このチェックがないと、入力されたメールアドレスがリクエスト元ユーザ本人のものであることを保証できません。したがって、後述するドメインに基づくアクセス制御の回避が可能になります。また、ユーザーが登録時にメールアドレスを誤入力した場合にアカウントを乗っ取られる可能性があります。

request.auth.token.email_verified

通常のアプリケーションであれば上に挙げるチェックで十分ですが、例えばクローズドなアプリケーションにおいて、特定のドメインに属するメールアドレスを所有しているユーザに限って認証チェックを成功させたい場合、ドメインの確認を追加的に行いましょう。

request.auth.token.email.matches('^(.+)@flatt[.]tech$')

これらの条件群は関数にまとめた上で、認証が必要なパスと操作に対する allow 式において確実に呼び出されていることを確認しましょう。以下に例を示します。

allow read: if checkAuthentication(request.auth);

function checkAuthentication(auth) {
  return auth != null &&
         auth.token.email.matches('^(.*)@flatt[.]tech$')
         auth.token.email_verified &&
         auth.token.firebase.sign_in_provider == 'google.com';
}

権限のチェック

概要

適切な権限を持たない主体によるデータの操作から Firestore を守るために、セキュリティルールにおいて、アクセス元ユーザのロールとアプリケーションの仕様に基づいた権限のチェックを行う必要があります。

権限のチェックは、先ほど紹介した Firebase Authentication から提供される認証情報にカスタムクレーム (Custom Claim) を定義することで実施することが一般的です。カスタムクレームは、Admin SDK から提供される setCustomUserClaims 関数を用いることで定義できます。

チェックの実例

例として、ユーザの情報に基づいて role というカスタムクレームを設定するコードを以下に示します。なお、以下では role の値として、通常ユーザーには user 、管理者ユーザーには admin という値を設定することにします。

const getRole = user => {
  return /* user が admin の要件を満たす */ ? 'admin' : 'user'
}

functions.auth.user().onCreate(async user => {
  await admin.auth().setCustomUserClaims(user.uid, {
    role: getRole(user)
  })
})

定義したカスタムクレームはセキュリティルールにおいて request.auth.token.<カスタムクレーム名> で参照することができます。

function isAdmin(auth) {
  return auth.token.role == 'admin';
}

allow read: if isAdmin(request.auth);

代替策として、アカウント作成時に Firestore へとユーザ情報が記載されたドキュメントを格納しておき、権限チェックの際に当該ドキュメントを参照するという手法も存在します。

const getRole = user => ...

functions.auth.user().onCreate(async user => {
  await firestore.collection('users').doc(uid).set({
    role: getRole(user)
  })
}

この場合、セキュリティルールでは get 関数を用いてドキュメントを参照することができます。

function isAdmin(uid) {
  return get(/databases/$(database)/documents/users/$(uid)).data.role == 'admin';
}

allow read: if isAdmin(request.auth.uid);

一方で、当該手法は以下に示すデメリットが存在するため、特別な理由がない限りはカスタムクレームを活用することを推奨します。

  • get 関数の呼び出しが Firestore における課金対象のオペレーションとしてカウントされる。
  • get 関数の呼び出しは、あるリクエストに対して10回までしか行えない。3
  • Cloud Storage のセキュリティルールにおいて権限チェックを行う場合に Firestore のドキュメントを参照することができない。

バリデーションチェック

概要

事前に想定されたデータスキーマの仕様に沿っていないデータを Firestore に作成されることを防ぐためにバリデーションチェックを行う必要があります。バリデーションチェックにおいて確認する事項を以下に示します。

  • データが有するべきフィールドの検証
  • 各フィールドに代入された値に対するデータ型の検証
  • 各フィールドに代入された値やそのデータ型に応じたフォーマットの検証 (各フィールドに代入される値のフォーマットや候補が事前に定義されている場合)

次項において、これらの検証を行う際のセキュリティルールの例を示します。

チェックの実例

まず、データが有するべきフィールドの検証を行う必要があります。例えば、ある商品を表すドキュメントのデータ型が以下だったとしましょう。4

type Category = 'FOOD' | 'BOOK' | 'CLOTHES' | 'OTHER'

type ItemStatus = {
    sold: false;
} | {
    sold: true;
    soldAt: Date;
}

type Item = {
    name: string;
    price: number;
    category: Category;
    status: ItemStatus;
}

この時、事前に定義されたフィールドがデータに含まれていること及び定義されていないフィールドがデータに含まれていないことを確認する必要があります。この場合、リクエストに含まれるデータ (request.resource.data) におけるキーのリストを取得 (Map.keys()) した上で、そのリストに必要なキーが含まれていることを List.hasAll(list) を用いてチェックするとともに、不必要なキーが含まれていないことを List.hasOnly(list)を用いてチェックすることが有効です。5

なお、List.hasAll(list) は引数 list の全ての要素が List に含まれているかどうかを判定します。また、List.hasOnly(list) は引数 list に含まれる要素のみで List が構成されているかどうかを判定します。

function isValidItem(item) {
  return item.keys().hasAll(['name', 'price', 'category', 'status']) &&
         item.keys().hasOnly(['name', 'price', 'category', 'status']);
}

ところで、アプリケーション開発においては、フェーズが進むにつれてその仕様が変更され、Firestore に格納するデータのスキーマが更新されることが多々あります。例えば、あるフィールドを含むデータと含まないデータが同一のコレクション中に混在することがあります。このような場合、存在していても、していなくてもよいフィールドを hasOnly の引数にのみ追加することが有効です。

以下に、例を示します。

function isValidFoo() {
  let data = request.resource.data;
  return data.keys().hasAll(['required_1', ..., 'required_N']) &&
         data.keys().hasOnly(['required_1', ..., 'required_N', 'optional_1', ..., 'optional_N']);
}

ただし、このように同一のコレクション内で異なるデータスキーマのドキュメントが混在させることは、ややもすればセキュリティルールを記述する上で混乱を招いたり、そのデータを扱うアプリケーションの実装における複雑性を高めたりすることにつながります。そのため、マイグレーション等を確実に行うようにすることで、可能な限りにおいて同一のコレクションに格納されたドキュメントが同一のデータスキーマに従っている状態を維持するようにしてください。

さて、データが有するべきフィールドの検証が完了したので、次に各フィールドに代入された値に対するデータ型の検証に進みましょう。データ型の検証は <フィールド名> is <型名> という構文を用いて実施します。Firestore に格納される値の主なデータ型の型名を以下に示します。

データ型 型名
文字列型 string
数値型・整数型・浮動小数点数型 number, int, float
ブール型 bool
リスト型 list
マップ型 map
タイムスタンプ型 timestamp

以下に、データ型の検証を行うセキュリティルールの例を、先ほど示した isValidItem に追加して示します。ここで、フィールド status においては単に status がマップ型であることを検証しているだけでなく、それに含まれるフィールドの型も検証していることに注意してください。このように、マップ型のフィールドのバリデーションにおいては、その内容を再帰的に検証していくことが必要です。

function isValidItem(item) {
         // フィールドの検証
  return item.keys().hasAll(['name', 'price', 'category', 'status']) &&
         item.keys().hasOnly(['name', 'price', 'category', 'status']) &&
         // データ型の検証
         item.name is string &&
         item.price is int &&
         item.category is string &&
         (
           item.status is map &&
           item.status.keys().hasAll(['sold']) &&
           item.status.keys().hasOnly(['sold', 'soldAt']) &&
           item.status.sold is bool &&
           (!('soldAt' in item.status.keys()) || item.status.soldAt is timestamp)
         );
}

最後に、各フィールドに代入された値やそのデータ型に応じたフォーマットの検証を行います。

まず、代入された値に応じたフォーマットの検証からはじめましょう。例えば、先ほど示した Item['category']Item['status'] のように、フィールドの中にはあらかじめ代入され得る値が仕様で定められている場合があります。この場合、型レベルのチェックではなく、リテラル値や代入され得る値のリストと in 演算子を用いた値レベルのチェックを行うことが有効です。

以下に、Item['category']FOOD, BOOK, CLOTHES, OTHER のいずれかの値であること、及び、Item['status']ItemStatus 型に適合した値であることを強制するセキュリティルールの例を示します。

function isValidItem(item) {
         // フィールドの検証
  return item.keys().hasAll(['name', 'price', 'category', 'status']) &&
         item.keys().hasOnly(['name', 'price', 'category', 'status']) &&
         // データ型の検証
         item.name is string &&
         item.price is int &&
         item.category is string && item.category in ['FOOD', 'BOOK', 'CLOTHES', 'OTHER'] && // 追加
         (
           item.status is map &&
           item.status.keys().hasAll(['sold']) &&
           item.status.keys().hasOnly(['sold', 'soldAt']) &&
           item.status.sold is bool &&
           // 更新
           (
             (
               item.status.sold &&
               'soldAt' in item.status &&
               item.status.soldAt is timestamp
             ) ||
             (
                 !item.status.sold &&
                 !('soldAt' in item.status)
             )
           )
         );
}

次に、データ型に応じたフォーマットの検証を説明します。この検証において、特に注意が必要なのが string 型とリスト型です。これらの型の値を持つフィールドは、その値のサイズ (すなわち、文字列長と要素数) を検証しておかないと、非常に大きなサイズを持つデータを Firestore に作成されることでデータサイズに応じた課金額を上昇させられたり、アプリケーションのパフォーマンスを大きく低下させられたりする恐れがあります。このサイズの検証には、List.size 関数や String.size 関数を利用することができます。

以下に、isValidItem 関数において Item['name']に格納された文字列の長さに関する制限を行うセキュリティルールの例を示します。

function isValidItem(item) {
         // フィールドの検証
  return item.keys().hasAll(['name', 'price', 'category', 'status']) &&
         item.keys().hasOnly(['name', 'price', 'category', 'status']) &&
         // データ型の検証
         item.name is string && item.name.size() < 10000 && // 追加
         item.price is int &&
         item.category is string && item.category in ['FOOD', 'BOOK', 'CLOTHES', 'OTHER'] &&
         (
           item.status is map &&
           item.status.keys().hasAll(['sold']) &&
           item.status.keys().hasOnly(['sold', 'soldAt']) &&
           item.status.sold is bool &&
           (
             (
               item.status.sold &&
               'soldAt' in item.status &&
               item.status.soldAt is timestamp
             ) ||
             (
                 !item.status.sold &&
                 !('soldAt' in item.status)
             )
           )
         );
}

また、タイムスタンプ型の値についても注意が必要です。これらの型に格納された値をチェックしておかないと、ドキュメントの作成日時や更新日時を不正に操作される恐れがあります。以下に、作成時及び更新時にタイムスタンプ値のチェックを行うルールの例を示します。

// data: request.resource.data
function validateCreateTimestamp(data) {
  return data.createdAt == request.time &&
         data.updatedAt == request.time;
}

// nextData: request.resource.data (リクエストに含まれるデータ)
// prevData: resource.data (リクエスト対象のパスに存在するデータ)
function validateUpdateTimestamp(nextData, prevData) {
  return nextData.createdAt == prevData.createdAt &&
         nextData.updatedAt == request.time;
}

実際にドキュメントを作成及び更新する場合、JavaScript であれば firebase.firestore.FieldValue.serverTimestamp メソッドを用いることでリクエスト時刻をフィールドに設定することができます。6この値は Firestore においてリクエストが処理された時刻へと自動的に変換されます。

firebase
  .firestore()
  .collection('items')
  .add({  
    /* 各フィールド */
    createdAt: firebase.firestore.FieldValue.serverTimestamp(),  
    updatedAt: firebase.firestore.FieldValue.serverTimestamp()
  })

ここまでを通して、以下に示す様々な検証(再掲)を行ってきました。

  • データが有するべきフィールドの検証
  • 各フィールドに代入された値に対するデータ型の検証
  • 各フィールドに代入された値やそのデータ型に応じたフォーマットの検証

さて、ここまですればもう十分だろうと言いたくなるところですが、結論から述べるとまだ不十分です。以下では、アプリケーションの仕様に基づいた作成、更新あるいは削除操作における正当性のチェックについて説明します。

アプリケーションの仕様に基づいた操作の正当性チェック

概要

認証のチェック、認可のチェック、バリデーションチェックを行うルールを定義することで、Firestore に格納されるデータのレベルにおいてはセキュアな環境を実現することができます。一方で、アプリケーションの仕様に基づいた操作の正当性を保証するようなルールが定義されていないと、Firestore に格納されたデータをアプリケーションから読み込んだり書き込んだりする際に、その操作の結果とアプリケーションの仕様に矛盾が生じる場合があります。

以下では、アプリケーションの仕様に基づいた操作の正当性チェックを行う際の実例を示します。

チェックの実例

ここでは、ある出前サービスのデータを Firestore に格納する場合を考えましょう。当該サービスでは、お客さんがあるお店に対して出前を注文した際にその注文を示すドキュメントが作成され、お店が出前を開始した時点及びお客さんが出前を受け取った時点でドキュメントが更新されるものとしましょう。

ここで、ステータスを含んだある注文を示すドキュメントのスキーマは以下のようになるでしょう。

type Order = {
    status: 
    | 'ORDERED'    // 注文済み(未配達)
    | 'DELIVERING' // 配達中
    | 'DELIVERED'; // 配達完了
  ...
}

まず、先ほど紹介した内容に従ってバリデーションチェックを行いましょう。関係する部分のみ抜粋して以下に示します。

function isValidOrder(order) {
  return order.status in ['ORDERED', 'DELIVERING', 'DELIVERED'] &&
         ...
}

allow create, update: isValidOrder(request.resource.data) && ...;

上記のルールは一見正しいようですが、大きな問題を抱えています。この場合、ドキュメントを操作する主体がお客さんであってもお店であっても、任意のステータスを設定することができてしまいます。また、ドキュメント作成時にステータスを DELIVEREDに設定したり、ドキュメント更新時にステータスを ORDERED に設定したりできてしまいます。この結果、以下のようなリスクが考えられます。

  • お店がステータスを DELIVERED にしたドキュメントを作成することで注文をねつ造する。
  • お客さんが商品を受け取ったにも関わらずステータスを ORDEREDDELIVERING に設定することで、まだ出前を受け取っていないように見せかける。

この問題を解決するためには、アプリケーションの仕様に基づいた操作の正当性チェックを行う必要があります。以下に、上記の問題を解決するようなセキュリティルールの例を示します。

// お客さんかどうか判定する
function isCustomer(uid) { ... }

// お店かどうか判定する
function isShop(uid) { ... }

function isValidOrderCreate(newOrder) {
  return newOrder.status == 'ORDERED' &&
         isCustomer(request.auth.uid) &&
         ...
}

function isValidOrderUpdate(nextOrder, prevOrder) {
  return (
               (
                 newOrder.status == 'DELIVERING' &&
                 prevOrder.status == 'ORDERED' &&
                 isShop(request.auth.uid)
               ) ||
               (
                 newOrder.status == 'DELIVERED' &&
                 prevOrder.status == 'DELIVERING' &&
                 isCustomer(request.auth.uid)
               )
         ) &&
         ...
}

allow create: isValidOrderCreate(request.resource.data) && ...;
allow update: isValidOrderUpdate(request.resource.data, resource.data)

上記のルールにより、ドキュメントの操作に関して以下の事項が保証されます。

  • 注文作成時のステータスは ORDERED であり、操作の主体はお客さんである。
  • 注文更新時は以下のいずれかの条件が満たされている。
    • 新しいステータスが DELIVERING、以前のステータスが ORDERED であり、操作の主体はお店である。
    • 新しいステータスが DELIVERED 、以前のステータスが DELIVERING であり、操作の主体はお客さんである。

もちろん、これら以外にも考えなければならないことはたくさんあります。例えば、あるお客さんが他人が注文したように見せかけるようなドキュメントを作成できないだろうか、あるお店が他店に行われた注文を示すドキュメントを操作することができないだろうかといった懸念が考えられます。これらの懸念がアプリケーションリリース時に残存しないようにするために、開発の初期においてルールの定義内容を意識したコレクション構成やドキュメントのフォーマットを設計するようにしましょう。

Column: リストに関する問題

本稿が執筆された時点では、セキュリティルールにおいてリストに含まれるそれぞれの要素に対して走査的にバリデーションチェックを行うような記法は存在しません。例えば「このリストの要素はすべて文字列型であり、その長さは1000文字以内である」といったようなルールを定義することはできません。

そのため、リスト型のフィールドが存在する場合は、それをサブコレクションとして切り出すことができないかどうかを検討してみてください。特に、当該リストの要素がマップ型であるなら自然にサブコレクションとして切り出すことができるでしょう。

サンプルアプリケーションの構築

最後に、ここまでに示した知識とテクニックを総動員して、セキュアな Firestore 環境を実現するセキュリティルールの定義にステップバイステップで取り組んでみましょう。以下では、Firestore を利用するアプリケーションとして出前サービスを取り上げます。

要件

Firestore の要件を以下に示します。

  • ユーザはお客さん (Customer) かお店 (Shop) である。
  • Firestore では以下の情報を取り扱う。
    • お客さんの情報: customers コレクションに格納する。ドキュメント ID は Firebase Authentication から付与される uidである。
    • お店の情報: shops コレクションに格納する。ドキュメント ID は Firebase Authentication から付与される uidである。
    • 商品 (Item) の情報: shops コレクションに格納されたドキュメントに付随する items コレクションに格納する。ドキュメント ID はランダムである。
    • 注文 (Order) の情報: orders コレクションに格納する。ドキュメント ID はランダムである。
  • 認証には Firebase Authentication において Google ログインを利用する。なお、メールアドレスは疎通確認が行われている必要がある。なお、初回ログイン時に以下の処理を実行する。
    • カスタムクレームroleに対して、お客さんなら CUSTOMER を、お店なら SHOP を設定する。
    • お客さんかお店かに応じたコレクションにユーザの情報を示すドキュメントを作成する。

ドキュメントのスキーマ

以下に、各コレクションに格納されるドキュメントのデータスキーマを示します。

type Customer = {
  name: string;    // 氏名
  email: string;   // メールアドレス
  address: string; // 住所
  createdAt: Date; // 作成日時
  updatedAt: Date; // 更新日時
}
type Shop = {
  name: string;   // 店名
  email: string;  // メールアドレス
  address: string;   // 住所
  link: string;      // ホームページへのリンク
  createdAt: Date;   // 作成日時
  updatedAt: Date;   // 更新日時
}
type Item = {
  name: string;         // 商品名
  description: string;  // 説明
  category:             // 商品カテゴリ
    | 'FOOD' 
    | 'BOOK' 
    | 'CLOTHES' 
    | 'OTHER';
  createdAt: Date; // 作成日時
  updatedAt: Date; // 更新日時
}
type Order = {
  customerId: string;   // お客さんのドキュメントID
  shopId: string;        // お店のドキュメントID
  itemId: string;       // 商品のドキュメントID
  quantity: number;     // 個数
  address: string;      // 配達先住所
  status:               // 配達状況
        | 'ORDERED'
        | 'DELIVERING'
        | 'DELIVERED';
  createdAt: Date;      // 作成日時
  updatedAt: Date;      // 更新日時
}

権限マトリクス

以下に、ユーザの種別とコレクション、及び操作内容に基づいた権限のマトリクスを示します。

customers コレクション

操作内容 \ 操作の主体 お客さん お店
get 自分のドキュメントに限り許可 許可しない
list 許可しない 許可しない
create 許可しない 許可しない
update 自分のドキュメントに限って許可
ただし、更新対象はメールアドレス及び住所のみ
許可しない
delete 許可しない 許可しない

shops コレクション

操作内容 \ 操作の主体 お客さん お店
get すべて許可 すべて許可
list すべて許可 すべて許可
create 許可しない 許可しない
update 許可しない 自分のドキュメントに限って許可
delete 許可しない 許可しない

items コレクション

操作内容 \ 操作の主体 お客さん お店
get 認証後であれば許可 自分のお店の商品に限って許可
list 認証後であれば許可 自分のお店の商品に限って許可
create 許可しない 自分のお店の商品に限って許可
update 許可しない 自分のお店の商品に限って許可
delete 許可しない 自分のお店の商品に限って許可

orders コレクション

操作内容 \ 操作の主体 お客さん お店
get 自分が注文した内容を示すドキュメントに限り許可 自店に注文された内容を示すドキュメントに限り許可
list 自分が注文した内容を示すドキュメントに限り許可 自店に注文された内容を示すドキュメントに限り許可
create 認証後であれば許可
ただし、ステータスは ORDERED でなければならない
許可しない
update 自分が注文した内容を示すドキュメントに限り許可
ただし、許可されるのはステータスを DELIVERED にする操作のみであり、更新前のステータスは DELIVERING でなくてはならない
自店に注文された内容を示すドキュメントに限り許可
ただし、許可されるのはステータスを DELIVERING にする操作のみであり、更新前のステータスは ORDERED でなくてはならない
delete 自分が注文した内容を示すドキュメントに限り許可
ただし、許可されるのはステータスが ORDERED の場合のみである。
許可しない

セキュリティルールの定義

基本形

まず、コレクションの初期定義を行いましょう。この時点では、不正な操作が行われないように、認証なしでも操作可能(オープンアクセス)なものを除いたすべての操作を拒否するようにしています。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /customers/{customerId} {
        allow get: if false;
        allow list: if false;
        allow create: if false;
        allow update: if false;
        allow delete: if false;
    }
    
    match /shops/{shopId} {
        allow get: if true;  // 認証なしでもOK
        allow list: if true; // (オープンアクセス)
        allow create: if false;
        allow update: if false;
        allow delete: if false;
    
        match /items/{itemId} {
            allow get: if false;
            allow list: if false;
            allow create: if false;
            allow update: if false;
            allow delete: if false;
        }
    }
    
    match /orders/{orderId} {
        allow get: if false;
        allow list: if false;
        allow create: if false;
        allow update: if false;
        allow delete: if false;
    }
  }
}

認証情報のチェック

まず、認証情報のチェックを行う関数を定義しましょう。認証情報のチェックはほぼ全てのドキュメント操作時に行われるため、関数のスコープを考慮して、すべての箇所から参照できるような位置に定義しておくと良いでしょう。

function checkAuthentication() {
         // 認証情報の存在可否
  return request.auth != null &&
         request.auth.token.email_verified &&
         request.auth.token.firebase.sign_in_provider == 'google.com';
}

では、認証が必要なドキュメントと操作の組に対して、当該関数を呼び出すようなルールを定義しましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /customers/{customerId} {
        allow get: if checkAuthentication();
        allow list: if false;
        allow create: if false;
        allow update: if checkAuthentication();
        allow delete: if false;
    }
    
    match /shops/{shopId} {
        allow get: if true;  // 認証なしでもOK
        allow list: if true; // (オープンアクセス)
        allow create: if false;
        allow update: if checkAuthentication();
        allow delete: if false;
    
        match /items/{itemId} {
            allow get: if checkAuthentication();
            allow list: if checkAuthentication();
            allow create: if checkAuthentication();
            allow update: if checkAuthentication();
            allow delete: if checkAuthentication();
        }
    }
    
    match /orders/{orderId} {
        allow get: if checkAuthentication();
        allow list: if checkAuthentication();
        allow create: if checkAuthentication();
        allow update: if checkAuthentication();
        allow delete: if checkAuthentication();
    }

    function checkAuthentication() {
      return request.auth != null &&
             request.auth.token.email_verified &&
             request.auth.token.firebase.sign_in_provider == 'google.com';
    }
  }
}

権限のチェック

まず、権限のチェックを行うために、リクエスト元ユーザの種別や、ドキュメントが示すユーザとリクエスト元ユーザが一致するかどうかを特定する関数を定義しましょう。これも、check_authentication 関数と同様に、すべての箇所から参照できるような位置に定義しておくと良いでしょう。

function isCustomer() {
  return request.auth.token.role == 'CUSTOMER':
}

function isShop() {
    return request.auth.token.role == 'SHOP';
}

function isOwnDocument(documentId) {
    return request.auth.uid == documentId;
}

function isMyOrder(order) {
  return (
            isCustomer() &&
            order.customerId == request.auth.uid
          ) ||
          (
            isShop() &&
            order.shopId == request.auth.uid
          );
}

では、特定の権限が必要なドキュメントと操作の組に対して、当該関数を呼び出すようなルールを定義しましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /customers/{customerId} {
        allow get: if checkAuthentication() &&
                      isCustomer() &&
                    isOwnDocument(customerId);
        allow list: if false;
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (
                         isCustomer() &&
                         isOwnDocument(customerId)
                       );
        allow delete: if false;
    }
    
    match /shops/{shopId} {
        allow get: if true;  // 認証なしでもOK
        allow list: if true; // (オープンアクセス)
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (isShop() && isOwnDocument(shopId));
        allow delete: if false;
    
        match /items/{itemId} {
            allow get: if checkAuthentication() &&
                      (
                        isCustomer() ||
                        (isShop() && isOwnDocument(shopId))
                      );
            allow list: if checkAuthentication() &&
                       (
                         isCustomer() ||
                         (isShop() && isOwnDocument(shopId))
                       );
            allow create: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId));
            allow update: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId));
            allow delete: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId));
        }
    }
    
    match /orders/{orderId} {
        allow get: if checkAuthentication() &&
                    isMyOrder(resource.data);
        allow list: if checkAuthentication() &&
                     isMyOrder(resource.data);
        allow create: if checkAuthentication() &&
                       isCustomer();
        allow update: if checkAuthentication() &&
                       isMyOrder(resource.data);
        allow delete: if checkAuthentication() &&
                       isCustomer() &&
                       isMyOrder(resource.data);
    }

    function checkAuthentication() {
      return request.auth != null &&
             request.auth.token.email_verified &&
             request.auth.token.firebase.sign_in_provider == 'google.com';
    }

    function isCustomer() {
      return request.auth.token.role == 'CUSTOMER';
    }

    function isShop() {
      return request.auth.token.role == 'SHOP';
    }

    function isOwnDocument(documentId) {
      return request.auth.uid == documentId;
    }

    function isMyOrder(order) {
      return (
                isCustomer() &&
                order.customerId == request.auth.uid
              ) ||
              (
                isShop() &&
                order.shopId == request.auth.uid
              );
    }
  }
}

バリデーションチェック

バリデーションチェックを行うために、各ドキュメントについて、バリデーションチェックの概要の節で示したチェックを行うような関数を定義しましょう。これは、認証や認可のチェックを行う関数とは異なり、バリデーションを行うドキュメントに対応したスコープにて定義しておくと見通しが良くなります。

では、バリデーションチェックを行うようなルールを各 match のブロック内に定義しましょう。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /customers/{customerId} {
        allow get: if checkAuthentication() &&
                      isCustomer() &&
                    isOwnDocument(customerId);
        allow list: if false;
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (
                         isCustomer() &&
                         isOwnDocument(customerId)
                       ) &&
                       validateCustomer(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data);
        allow delete: if false;

      function validateCustomer(customer) {
        return customer.keys().hasAll(['name', 'email', 'address', 'createdAt', 'updatedAt']) &&
               customer.keys().hasOnly(['name', 'email', 'address', 'createdAt', 'updatedAt']) &&
               customer.name is string && customer.name.size() < 1000 &&
               customer.email == request.auth.token.email &&
               customer.address is string && customer.address.size() < 1000 &&
               customer.createdAt is timestamp &&
               customer.updatedAt is timestamp;
      }
    }
    
    match /shops/{shopId} {
        allow get: if true;  // 認証なしでもOK
        allow list: if true; // (オープンアクセス)
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (isShop() && isOwnDocument(shopId)) &&
                       validateShop(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data);
        allow delete: if false;

      function validateShop(shop) {
        return shop.keys().hasAll(['name', 'email', 'address', 'link', 'createdAt', 'updatedAt']) &&
               shop.keys().hasOnly(['name', 'email', 'address', 'link', 'createdAt', 'updatedAt']) &&
               shop.name is string && shop.name.size() < 1000 &&
               shop.email == request.auth.token.email &&
               shop.address is string && shop.address.size() < 1000 &&
               shop.link is string && shop.link.matches('^https?://(.+)$') &&
               shop.createdAt is timestamp &&
               shop.updatedAt is timestamp;
      }
    
        match /items/{itemId} {
            allow get: if checkAuthentication() &&
                      (
                        isCustomer() ||
                        (isShop() && isOwnDocument(shopId))
                      );
            allow list: if checkAuthentication() &&
                       (
                         isCustomer() ||
                         (isShop() && isOwnDocument(shopId))
                       );
            allow create: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId)) &&
                         validateItem(request.resource.data) &&
                         validateCreateTimestamp(request.resource.data);
            allow update: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId)) &&
                         validateItem(request.resource.data) &&
                         validateUpdateTimestamp(request.resource.data, resource.data);
            allow delete: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId));


        function validateItem(item) {
          return item.keys().hasAll(['name', 'description', 'category', 'createdAt', 'updatedAt']) &&
                 item.keys().hasOnly(['name', 'description', 'category', 'createdAt', 'updatedAt']) &&
                 item.name is string && item.name.size() < 1000 &&
                 item.description is string && item.description.size() < 1000 &&
                 item.category in ['FOOD', 'BOOK', 'CLOTHES', 'OTHER'] &&
                 item.createdAt is timestamp &&
                 item.updatedAt is timestamp;
        }
        }
    }
    
    match /orders/{orderId} {
        allow get: if checkAuthentication() &&
                    isMyOrder(resource.data);
        allow list: if checkAuthentication() &&
                     isMyOrder(resource.data);
        allow create: if checkAuthentication() &&
                       isCustomer() &&
                       validateOrder(request.resource.data) &&
                       validateCreateTimestamp(request.resource.data);
        allow update: if checkAuthentication() &&
                       isMyOrder(resource.data) &&
                       validateOrder(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data);
        allow delete: if checkAuthentication() &&
                       isCustomer() &&
                       isMyOrder(resource.data);

      function validateOrder(order) {
        return order.keys().hasAll(['customerId', 'shopId', 'itemId', 'quantity', 'address', 'status', 'createdAt', 'updatedAt']) &&
               order.keys().hasOnly(['customerId', 'shopId', 'itemId', 'quantity', 'address', 'status', 'createdAt', 'updatedAt']) &&
               order.customerId is string && existsCustomer(order.customerId) &&
               order.shopId is string && existsShop(order.shopId) &&
               order.itemId is string && existsShop(order.shopId) && existsItem(order.shopId, order.itemId) &&
               order.quantity is int && 0 < order.quantity && order.quantity < 100 &&
               order.address is string && order.address.size() < 1000 &&
               order.status in ['ORDERED', 'DELIVERING', 'DELIVERED'] &&
               order.createdAt is timestamp &&
               order.updatedAt is timestamp;
      }
    }

    function checkAuthentication() {
      return request.auth != null &&
             request.auth.token.email_verified &&
             request.auth.token.firebase.sign_in_provider == 'google.com';
    }

    function isCustomer() {
      return request.auth.token.role == 'CUSTOMER';
    }

    function isShop() {
      return request.auth.token.role == 'SHOP';
    }

    function isOwnDocument(documentId) {
      return request.auth.uid == documentId;
    }

    function isMyOrder(order) {
      return (
                isCustomer() &&
                order.customerId == request.auth.uid
              ) ||
              (
                isShop() &&
                order.shopId == request.auth.uid
              );
    }

    function existsCustomer(customerId) {
      return exists(/databases/$(database)/documents/customers/$(customerId));
    }
    
    function getCustomer(customerId) {
      return get(/databases/$(database)/documents/customers/$(customerId)).data;
    }

    function existsShop(shopId) {
      return exists(/databases/$(database)/documents/shops/$(shopId));
    }

    function existsItem(shopId, itemId) {
      return exists(/databases/$(database)/documents/shops/$(shopId)/items/$(itemId));
    }

    function validateCreateTimestamp(data) {
      return data.createdAt == request.time &&
             data.updatedAt == request.time;
    }

    function validateUpdateTimestamp(nextData, prevData) {
      return nextData.createdAt == prevData.createdAt &&
             nextData.updatedAt == request.time;
    }
  }
}

アプリケーションの仕様に基づいた操作の正当性チェック

最後に、アプリケーションの仕様に基づいた操作の正当性チェックを行いましょう。考慮する必要がある点は以下のとおりです。

  • customers
    • 更新対象はメールアドレス及び住所のみである。
  • shops
    • 特になし。
  • items
    • 特になし。
  • orders
    • 更新対象はステータスのみである。
    • 配達先住所は注文したお客さんの住所と一致していなければならない。
    • 作成時のステータスは ORDERED でなければならない。
    • お店によるステータスの更新は ORDERED から DELIVERING へと更新するものでなければならない。
    • お客さんによるステータスの更新は DELIVERING から DELIVERED へと更新するものでなければならない。
    • お客さんによる注文の削除は、その注文のステータスが ORDERED の場合に限る。

では、これらの制約を課すルールを定義しましょう。

まず、更新を許可するフィールドを制限するためには、更新前と更新後で変更されたフィールドを抽出する必要があります。抽出ができたら、変更が行われたフィールド群が事前に認められているものかどうかを確かめましょう。以下に、このようなルールを定義する関数の例を示します。

function changedOnlySpecifiedKeys(nextData, prevData, keys) {
  return nextData.diff(prevData).changedKeys().hasOnly(
           keys.toSet().union(['updatedAt'].toSet())
         ) // updatedAt は暗黙的に更新を許可する
}

また、ステータスの遷移と配送先住所の内容を制限するために、以下の関数を定義しましょう。

// アプリケーションの仕様に基づいて注文の作成が行われているか
function validateOrderCreate(order) {
  return order.address == getCustomer(request.auth.uid).address &&
         order.status == 'ORDERED';
}

// アプリケーションの仕様に基づいて注文の更新が行われているか
function validateOrderUpdate(nextOrder, prevOrder) {
  return (
    (isCustomer() && nextOrder.status == 'DELIVERED' && prevOrder.status == 'DELIVERING') ||
    (isShop() && nextOrder.status == 'DELIVERING' && prevOrder.status == 'ORDERED')
  );
}

// アプリケーションの仕様に基づいて注文の削除が行われているか
function validateOrderDelete(order) {
  return order.status == 'ORDERED';
}

以下に、アプリケーションの仕様に基づく制約を課すルールを追加した、最終的なセキュリティルールの全体像を示します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /customers/{customerId} {
        allow get: if checkAuthentication() &&
                      isCustomer() &&
                    isOwnDocument(customerId);
        allow list: if false;
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (
                         isCustomer() &&
                         isOwnDocument(customerId)
                       ) &&
                       validateCustomer(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data) &&
                       changedOnlySpecifiedKeys(request.resource.data, resource.data, ['email', 'address']);
        allow delete: if false;

      function validateCustomer(customer) {
        return customer.keys().hasAll(['name', 'email', 'address', 'createdAt', 'updatedAt']) &&
               customer.keys().hasOnly(['name', 'email', 'address', 'createdAt', 'updatedAt']) &&
               customer.name is string && customer.name.size() < 1000 &&
               customer.email == request.auth.token.email &&
               customer.address is string && customer.address.size() < 1000 &&
               customer.createdAt is timestamp &&
               customer.updatedAt is timestamp;
      }
    }
    
    match /shops/{shopId} {
        allow get: if true;  // 認証なしでもOK
        allow list: if true; // (オープンアクセス)
        allow create: if false;
        allow update: if checkAuthentication() &&
                       (isShop() && isOwnDocument(shopId)) &&
                       validateShop(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data);
        allow delete: if false;

      function validateShop(shop) {
        return shop.keys().hasAll(['name', 'email', 'address', 'link', 'createdAt', 'updatedAt']) &&
               shop.keys().hasOnly(['name', 'email', 'address', 'link', 'createdAt', 'updatedAt']) &&
               shop.name is string && shop.name.size() < 1000 &&
               customer.email == request.auth.token.email &&
               shop.address is string && shop.address.size() < 1000 &&
               shop.link is string && shop.link.matches('^https?://(.+)$') &&
               shop.createdAt is timestamp &&
               shop.updatedAt is timestamp;
      }
    
        match /items/{itemId} {
            allow get: if checkAuthentication() &&
                      (
                        isCustomer() ||
                        (isShop() && isOwnDocument(shopId))
                      );
            allow list: if checkAuthentication() &&
                       (
                         isCustomer() ||
                         (isShop() && isOwnDocument(shopId))
                       );
            allow create: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId)) &&
                         validateItem(request.resource.data) &&
                         validateCreateTimestamp(request.resource.data);
            allow update: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId)) &&
                         validateItem(request.resource.data) &&
                         validateUpdateTimestamp(request.resource.data, resource.data);
            allow delete: if checkAuthentication() &&
                         (isShop() && isOwnDocument(shopId));


        function validateItem(item) {
          return item.keys().hasAll(['name', 'description', 'category', 'createdAt', 'updatedAt']) &&
                 item.keys().hasOnly(['name', 'description', 'category', 'createdAt', 'updatedAt']) &&
                 item.name is string && item.name.size() < 1000 &&
                 item.description is string && item.description.size() < 1000 &&
                 item.category in ['FOOD', 'BOOK', 'CLOTHES', 'OTHER'] &&
                 item.createdAt is timestamp &&
                 item.updatedAt is timestamp;
        }
        }
    }
    
    match /orders/{orderId} {
        allow get: if checkAuthentication() &&
                    isMyOrder(resource.data);
        allow list: if checkAuthentication() &&
                     isMyOrder(resource.data);
        allow create: if checkAuthentication() &&
                       isCustomer() &&
                       validateOrder(request.resource.data) &&
                       validateCreateTimestamp(request.resource.data) &&
                       validateOrderCreate(request.resource.data);
        allow update: if checkAuthentication() &&
                       isMyOrder(resource.data) &&
                       validateOrder(request.resource.data) &&
                       validateUpdateTimestamp(request.resource.data, resource.data) &&
                       validateOrderStatusUpdate(request.resource.data, resource.data) &&
                       changedOnlySpecifiedKeys(request.resource.data, resource.data, ['status']);
        allow delete: if checkAuthentication() &&
                       isCustomer() &&
                       isMyOrder(resource.data) &&
                       validateOrderDelete(resource.data);

      function validateOrder(order) {
        return order.keys().hasAll(['customerId', 'shopId', 'itemId', 'quantity', 'address', 'status', 'createdAt', 'updatedAt']) &&
               order.keys().hasOnly(['customerId', 'shopId', 'itemId', 'quantity', 'address', 'status', 'createdAt', 'updatedAt']) &&
               order.customerId is string && existsCustomer(order.customerId) &&
               order.shopId is string && existsShop(order.shopId) &&
               order.itemId is string && existsShop(order.shopId) && existsItem(order.shopId, order.itemId) &&
               order.quantity is int && 0 < order.quantity && order.quantity < 100 &&
               order.address is string && order.address.size() < 1000 &&
               order.status in ['ORDERED', 'DELIVERING', 'DELIVERED'] &&
               order.createdAt is timestamp &&
               order.updatedAt is timestamp;
      }

      // アプリケーションの仕様に基づいて注文の作成が行われているか
      function validateOrderCreate(order) {
        return order.address == getCustomer(request.auth.uid).address &&
               order.status == 'ORDERED';
      }

      // アプリケーションの仕様に基づいて注文の更新が行われているか
      function validateOrderStatusUpdate(nextOrder, prevOrder) {
        return (
          (isCustomer() && nextOrder.status == 'DELIVERED' && prevOrder.status == 'DELIVERING') ||
          (isShop() && nextOrder.status == 'DELIVERING' && prevOrder.status == 'ORDERED')
        );
      }

      // アプリケーションの仕様に基づいて注文の削除が行われているか
      function validateOrderDelete(order) {
        return order.status == 'ORDERED';
      }
    }

    function checkAuthentication() {
      return request.auth != null &&
             request.auth.token.email_verified &&
             request.auth.token.firebase.sign_in_provider == 'google.com';
    }

    function isCustomer() {
      return request.auth.token.role == 'CUSTOMER';
    }

    function isShop() {
      return request.auth.token.role == 'SHOP';
    }

    function isOwnDocument(documentId) {
      return request.auth.uid == documentId;
    }

    function isMyOrder(order) {
      return (
                isCustomer() &&
                order.customerId == request.auth.uid
              ) ||
              (
                isShop() &&
                order.shopId == request.auth.uid
              );
    }

    function changedOnlySpecifiedKeys(nextData, prevData, keys) {
      return nextData.diff(prevData).changedKeys().hasOnly(
             keys.toSet().union(['updatedAt'].toSet())) // updatedAt は暗黙的に更新を許可する
    }

    function existsCustomer(customerId) {
      return exists(/databases/$(database)/documents/customers/$(customerId));
    }
    
    function getCustomer(customerId) {
      return get(/databases/$(database)/documents/customers/$(customerId)).data;
    }

    function existsShop(shopId) {
      return exists(/databases/$(database)/documents/shops/$(shopId));
    }

    function existsItem(shopId, itemId) {
      return exists(/databases/$(database)/documents/shops/$(shopId)/items/$(itemId));
    }

    function validateCreateTimestamp(data) {
      return data.createdAt == request.time &&
             data.updatedAt == request.time;
    }

    function validateUpdateTimestamp(nextData, prevData) {
      return nextData.createdAt == prevData.createdAt &&
             nextData.updatedAt == request.time;
    }
  }
}

これで完成です!

もし余力があれば、このセキュリティルールに脆弱性が存在しないか、また、機能の追加や修正に伴う仕様変更が発生した際に、どのようにセキュリティルールをアップデートしていけばよいかについて思考を巡らせてみてください。7

おわりに

本稿では、Firestore を用いたセキュアなアプリケーション構築を行うためのアプローチについて説明するとともに、そのアプローチを実現するセキュリティルールの記述例を複数取り上げました。また、今回紹介したアプローチの実践として、模擬的な出前サービスのセキュリティルールをステップバイステップで定義しました。本稿によって、セキュアなセキュリティルールの定義に向けたアプローチについて理解することができたでしょうか。

ところで、今回紹介した Firestore におけるコレクションやドキュメントの設計及びセキュリティルールの定義に関する注意すべき点は、実はほんの一部です。実際のアプリケーション開発においては、多種多様なユーザのロールに応じた細やかな権限管理、Firebase を構成する他のサービスとの連携 (Cloud Stoarge や Cloud Functions など)、頻繁に発生する仕様の変更とそれに伴うコレクション、ドキュメント構造やセキュリティルールのアップデートなどといった課題と戦っていかなければなりません。そのような中で、真にセキュアなセキュリティルールを維持し続けるのは困難を極めます。Firestore を用いたアプリケーション開発を行うにあたっては、常に「このようなコレクション構成でセキュリティルールを定義する上で困ることはないか」、「セキュリティルールについてアプリケーションの仕様と矛盾するような操作が許可されていないか」といった事項に特に注意してください。

本記事が、皆様のFirebaseを用いたアプリケーション開発の一助となれば幸いです。

なお、株式会社Flatt Security (https://flatt.tech/) では、専門のセキュリティエンジニアによる Firebase に対する脆弱性診断サービスを提供しています(もちろん、Firebase を活用しているアプリケーション全体の診断も可能です)。もし、Firebase を用いた開発におけるセキュリティ上の懸念事項が気になる場合や、実際に診断について相談したいという場合は、ぜひ下記バナーからお問い合わせください。

では、ここまでお読み頂きありがとうございました。

更新履歴

2022年3月22日

「Google ログインを認証プロバイダに用いた場合に request.auth.token.email_verified は常に true である」という旨の記述について、2022 年 3 月に再調査したところ、再調査時点では正しくない情報であると判明したため削除しました。 (なお、これが執筆時点の弊社の検証不足によるものか、Firebase側での仕様変更によるものかは再調査時点では判断することはできません。ご了承ください。)


  1. Firebase では Firestore 以外にも Realtime Database という NoSQL 型データベースが利用できます。しかしながら、Realtime Database よりも Firestore の方がデータ構造の柔軟性が高い上に、データ取得時にクエリを利用可能であったり、セキュリティルールの表現力が高かったりするといった長所が多数存在するため、現在では Firestore を利用することが一般的です。

  2. セキュリティルールは Firestore だけではなく Realtime Database や Cloud Storage でも利用することができます。なお Realtime Database では JSON ライクな構文を用いて、Firestore 及び Cloud Storage では DSL を用いてセキュリティルールを定義します。

  3. https://firebase.google.com/docs/firestore/quotas#security_rules

  4. 本記事では、データ型の定義を示す際に TypeScript の記法を利用します。

  5. in 演算子を用いて <フィールド名> in request.resource.data.keys() とすることでフィールドの存在判定を行っても構いませんが、ドキュメントに含まれるフィールドの数が多くなるにつれて記述量が多くなってしまうため、本文に紹介している手法をおすすめします。

  6. もちろん、その他の言語や環境においても同様のメソッドが用意されています。

  7. 例えば、お客さんが注文を行った後、お店が注文された商品の値段を不正に引き上げるおそれがありそうです。この問題に対処するためには、どのようにデータ構造を修正すればよいでしょうか。また、どのようなセキュリティルールを定義すればよいでしょうか。