🐈

Firebase Firestoreを絡めた、Storageのセキュリティールールを書く

2023/01/04に公開約13,600字

2022年9月に、FirebaseからAnnouncing cross-service Security Rulesという記事が公開され、Firebase Storage(以下Storage)のセキュリティルールから、Firebase Firestore(以下Firestore)のデータを参照しつつ、セキュリティルールを構築することが可能になりました。

それ以前は、Firestoreのデータをstorage.rules上で参照できないため、Storageのセキュリティを強固なものにしようとすると

  • Auth Userのuidを絡めたファイルパスを定義してrequest.auth.uidと比較してアクセス権を定める
  • Auth UserのCustom Claimに特定の情報を付与し、それを参照する
  • ファイルのmetadataに特定の情報を書き込んで、それを参照する
  • ファイルの拡張子、ファイルサイズ、Content-Typeのバリデーション

といった方法くらいしか取れませんでした。
例えば、Articleというドキュメントがあって、article.isPublic = falseの時に、article/image.pngのreadを拒否する、といったルールを書くのが難しいというかほぼ不可能に近かったです。

(このようなケースでは、Storageのルールは開けずに、事前にtoken付きの、ファイルのURLを生成して、それをFirestoreのデータに書き込んでFirestore側だけで制御する、といった方法をとっている人もいたかと思います)

今回は、Storageのセキュリティルール上でFirestoreのデータを活用したルールの書き方の紹介とテストの書き方、注意点などをまとめてみました。

準備

Cross-Service Security Rulesを有効にする

まずは、storage.rulesからFirestoreのデータを参照できるようにするために、Cross-Service Security Rulesを有効にする必要があります。
一番手っ取り早い方法は、以下のコードを、web上のFirebaseのコンソール、Storageのルールを更新する画面で追記し、デプロイを試みる方法です。

rules_version = '2';
service firebase.storage {
  // (略)
  function test() {
    return firestore.exists(/databases/(default)/documents/tests/test);
  }
}

この記述は特に既存のルールと干渉することはないので問題はありません(ないはず)。
これを追記することで、デプロイ実行時に以下のようなダイアログが表示され、権限を持った新しいサービスアカウントを作成するかどうか聞かれるので、「権限を付与」を押します

デプロイ後は、上記のコードをすぐに削除して問題ありません。

あるいは、最新のfirebase-toolsをインストールし、同様にfirestoreを用いる関数を記述したstorage.rulesファイルをCLI経由でデプロイする場合にも、有効にする(サービスアカウントを作成するかどうか)かどうか求められると思います。

上記の操作で新たに作られたサービスアカウントは、GCP上のIAMのページで、「Google 提供のロール付与を含める」のチェックボックスを有効にした状態にすると見つけられます。suffixが @gcp-sa-firebasestorage.iam.gserviceaccount.comとなっているものが、今回作られたものになります。

または、GCP上で自分で設定、管理できる方は直接GCPのIAMを開いたり、管理しているファイルを更新して権限の更新作業をしましょう。上記の作業で新たに作成されるサービスアカウントに設定されている権限は以下のとおりです

  • datastore.entities.get
  • storage.buckets.get
  • storage.buckets.getIamPolicy
  • storage.objects.create
  • storage.objects.delete
  • storage.objects.get
  • storage.objects.getIamPolicy
  • storage.objects.list
  • storage.objects.update

(Optional)最新のfirebase-tools, Emulator, rules-unit-testingを準備

Optionalと書きましたが、基本CLI経由でデプロイしたり、Emulatorを使ったセキュリティルールをしっかり書いている人は必須になります。それぞれインストールをしていきます。

rules-unit-testingに関してはv2以上であれば問題ありません。
firebase-toolsに関しては、なるべく最新のバージョンのものを使いましょう(v11.10.0以上)。このバージョン以降のfirebase-toolsを使わないと、CLI経由でのデプロイやEmulatorの実行時に、storage.rules上に書かれたfirestore系の関数の書き方や構文を正しく解釈できず、エラーが発生します。

新しく導入された書き方

今回のアップデートで使用できるようになったのは、firestore.get() firestore.exists()の2つの関数と、FirestoreのドキュメントのPathを表す構文です
これらに関しては、firestore.rulesで用いられているものと同様のもので、firestoreというprefixがつきます。

// ドキュメントのパスを記述する
/databases/(default)/documents/articles/$(articleId)
path("/databases/(default)/documents/foo/bar")

// 指定のドキュメントが存在しているかどうか
firestore.exists(/databases/(default)/documents/articles/$(articleId))

// 指定のドキュメントを取得 (.data.XXXで値にアクセス可能)
firestore.get(/databases/(default)/documents/articles/$(articleId))
firestore.get(/databases/(default)/documents/articles/$(articleId)).data.isPublic

なお、ドキュメントのパスは常に /databases/(default)/documents/ から始める必要があります。また、現時点では同じFirebase Project上にあるFirestoreへのアクセスのみ行えるため、/databases/anotherProject/documentsのように書いても、anotherProjectのFirestoreの参照はできません。

便利関数

今までの私のセキュリティルールに関する記事を見てくださっている方にとっては馴染みのあるものですが、上記の関数を使いやすくしたり、ドキュメントのPathの記述を楽にする便利関数を導入しておくと良いでしょう。

function documentPath(paths) {
  return path([
    ['databases', '(default)', 'documents'].join('/'),
    paths.join('/')
    ].join('/')
  );
}

function getData(path) {
  return firestore.get(path).data;
}
// Before
return firestore
  .get(/databases/(default)/documents/articles/$(articleId))
  .data
  .isPublic;
// After
return getData(documentPath(["articles", articleId])).isPublic

少しでも記述量を減らしてシンプルに保つのが大切です 🍵

テストを書く

Firestoreを絡めたルールが書けたら、実際の動作確認とセキュリティルールのテストを書いてみましょう。

Storageのセキュリティルールの書き方に関しては、こちらの記事も参考にしてください。テストファイルの作成方法やread/writeのオペレーションの検証の仕方をまとめています。

https://zenn.dev/sgr_ksmt/articles/e6e7dfaf1851d1

(上記の記事に書かれている、基本的なところはこの記事では割愛します。)

例として、今回は以下のセキュリティルールを書いたと想定し、readのルールに対するテストを書きましょう。

セキュリティルール

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /articles/{articleId}/{allPaths=**} {
      allow read: if request.auth != null
        && getData(documentPath(["articles", articleId])).isPublic;
    }
  }
  function documentPath(paths) {
    return path([
      ['databases', '(default)', 'documents'].join('/'),
      paths.join('/')
      ].join('/')
    );
  }

  function getData(path) {
    return firestore.get(path).data;
  }
}

テストコード

長いためタップしてください
import "jest";
import * as ftest from "@firebase/rules-unit-testing";
import * as fs from "fs";
import { assertFails, assertSucceeds } from "@firebase/rules-unit-testing";

let testEnv: ftest.RulesTestEnvironment;
beforeAll(async () => {
  testEnv = await ftest.initializeTestEnvironment({
    projectId: "your-real-project-id",
    firestore: {
      rules: fs.readFileSync("./firestore.rules", "utf8"),
    },
    storage: {
      rules: fs.readFileSync("./storage.rules", "utf8"),
    },
  });
});
beforeEach(async () => {
  await testEnv.clearStorage();
  await testEnv.clearFirestore();
});
afterAll(async () => await testEnv.cleanup());

const filePath = "articles/article/image.png";
const documentPath = "articles/article";

describe("Article", () => {
  describe("read", () => {
    beforeEach(async () => {
      await testEnv.withSecurityRulesDisabled(async (context) => {
        await context.storage().ref(filePath).put(Buffer.alloc(100)).then();
      });
    });

    describe("article exists", () => {
      describe("isPublic is true", () => {
        beforeEach(async () => {
          await testEnv.withSecurityRulesDisabled(async (context) => {
            await context.firestore().doc(documentPath).set({ isPublic: true });
          });
        });

        describe("user is authenticated", () => {
          it("succeed to get a file", async () => {
            await assertSucceeds(
              testEnv
                .authenticatedContext("user")
                .storage()
                .ref(filePath)
                .getDownloadURL()
            );
          });
        });

        describe("user is not authenticated", () => {
          it("fail to get a file", async () => {
            await assertFails(
              testEnv
                .unauthenticatedContext()
                .storage()
                .ref(filePath)
                .getDownloadURL()
            );
          });
        });
      });

      describe("isPublic is false", () => {
        beforeEach(async () => {
          await testEnv.withSecurityRulesDisabled(async (context) => {
            await context
              .firestore()
              .doc(documentPath)
              .set({ isPublic: false });
          });
        });
        describe("user is authenticated", () => {
          it("fao; to get a file", async () => {
            await assertFails(
              testEnv
                .authenticatedContext("user")
                .storage()
                .ref(filePath)
                .getDownloadURL()
            );
          });
        });

        describe("user is not authenticated", () => {
          it("fail to get a file", async () => {
            await assertFails(
              testEnv
                .unauthenticatedContext()
                .storage()
                .ref(filePath)
                .getDownloadURL()
            );
          });
        });
      });
    });

    describe("article does not exist", () => {
      describe("user is authenticated", () => {
        it("fao; to get a file", async () => {
          await assertFails(
            testEnv
              .authenticatedContext("user")
              .storage()
              .ref(filePath)
              .getDownloadURL()
          );
        });
      });

      describe("user is not authenticated", () => {
        it("fail to get a file", async () => {
          await assertFails(
            testEnv
              .unauthenticatedContext()
              .storage()
              .ref(filePath)
              .getDownloadURL()
          );
        });
      });
    });
  });
});

解説

テストを書く時のポイントとしては

  • 実際のプロジェクトIDを使う(下記注意点参照)
  • withSecurityRulesDisabled関数を使いセキュリティルールが無効になっている状態で、Storage/Firestoreのテストデータを必要に応じて作成する
  • (jestの場合) beforeEachで、都度FirestoreとStorageのテストデータをクリアする
  • (jestの場合) --runInBand オプションをつけて、直列で実行

といった点になります。実際のプロジェクトIDを使ってenvironmentを初期化する都合で、テスト間/ファイル間の環境のコンフリクトを防ぐためにも、テストは直列で実行するのが吉です。

これで、下記のいずれかの条件の場合は、articles/{articleId}/以下に保存されているファイルを取得することができなくなるのを検証するテストが実行できます。

  • 認証されていないユーザー
  • articles/{articleId}ドキュメントが存在しない
  • articles/{articleId}ドキュメントの isPublicがtrueでない

注意点

確実に進化してきている(Storageの)セキュリティルールですが、現状いくつかの注意点や制限があるので紹介しておきます。

レイテンシの問題

同じリージョン内ではないと、サービス間の通信のレイテンシの問題が起き、セキュリティルールの評価に時間がかかり、結果としてダウンロード/アップロードが遅くなる可能性があります。
同リージョン内であれば、レイテンシはおよそ75ms増加するようです。ただ、ルールの評価中に、Firestoreへのアクセスが行われない場合は増加することはないので、Cross Service Security Rulesを有効にしただけで増えることはないようです。

原文

Firestore and Storage resources are located close by or in the same location. Requests for Storage resources located in the same region as the Firestore resources may see a latency increase of ~75ms for each Firestore read. If requests had to travel between distant locations, the latency could be higher; for example, for an even distribution of regions around the globe, we estimate an average latency increase of ~200ms. If your rules do not use any of these function calls, you should see no latency impact, even if the feature is enabled for your project.

1回のルールの評価で参照できるドキュメントの数は2つまで

レイテンシの増加を抑えるために、現時点では1回のルールの評価の中で、参照できるドキュメントの数は2つまでとなっているようです。3つ以上のユニークなドキュメントの参照があると、そのルールの評価はエラーとなり失敗します。 (英語だと "more than two unique documents" と書いてあって私たち日本人にとっては紛らわしいですが、3つ以上です)
ただし、ドキュメントAを評価中に2回参照した場合は、2回目はキャッシュが効くようなのでカウントは1つとしてみなされるようです。

  • ✅これは大丈夫そう
if firestore.get(documentA).data.isFoo 
  && firestore.get(documentB).data.isBar;

if firestore.get(documentA).data.isFoo 
  && firestore.get(documentB).data.isBar
  && firestore.get(documentA).data.isBaz;

function bar() {
  let documentA = firestore.get(documentB).data;
  let documentB = firestore.get(documentB).data;
  return documentA.isFoo
    && documentB.isBar
    && documentA.isBaz; 
}
  • ❌これはだめそう
if firestore.get(documentA).data.isFoo 
  && firestore.get(documentB).data.isBar
  && firestore.get(documentC).data.isBaz;

Firestoreのreadコストの増加

storage.rulesでFirestoreのデータを参照する場合、getexistsの関数が1回実行される毎に1read発生することになります。なのでサービス規模によっては、Storageのアクセス数に伴ってFirestoreへのreadコストも増える可能性があります。

StorageとFirestoreのデータの書き込まれる順番、データが事前に存在しているかのタイミングに注意

現時点ではfirestore.get()firestore.exists()の2つしか提供されておらず、Firestoreのセキュリティルールに存在しているexistsAfter()getAfter()は使えません。
そのため、ファイルのアップロード時に、関連するFirestoreのデータが同時に書き込まれたかどうかの判定をすることができません。

(そもそも、StorageとFirestoreの同時書き込み(バッチ処理やトランザクション)は提供されていないので自明ではありますが...)

なので、Storageのセキュリティルール上でFirestoreのデータを参照する場合かつ、writeの権限に関連するものに関しては、クライアント側でもデータをFirebaseに書き込む順番やタイミングを考えて設計する必要があります。

Emulatorを使ったテストでは実際のproject_idの指定が必要

Cloud FunctionsのEmulatorを動かし、FirestoreのEmulatorと連携を取る時と同様に、実際のproject_idを指定してテストライブラリを初期化しないと、Emulator間で連携ができずうまくいきません。
実際のプロジェクトのIDというのは、Cross-Service Security Rulesを有効にし、適切な権限を持った @gcp-sa-firebasestorage.iam.gserviceaccount.com のサービスアカウントが追加されているプロジェクトのIDとなります。
なのでここで指定するプロジェクトのIDが開発環境やテスト環境のものであれば、有効にした本番環境等と同様に、Cross-Service Security Rulesを有効にしてサービスアカウントの追加をしておく必要があります。

セキュリティルールも、テストの記述も正しいのにテストが成功せず、意図せず403エラーが出力される場合は、この部分を確認しましょう。

無効にしたいとき

Firestoreを絡めたセキュリティルールを構築する必要がなくなり、この機能を完全に無効にしたい場合は、次の手順で無効にする作業を行います。

  • firestore.get()などを含まない状態のセキュリティルールをデプロイする
  • GCPのIAMのページで@gcp-sa-firebasestorage.iam.gserviceaccount.comがsuffixにつくサービスアカウントを削除する

IAMを先に削除してしまうと、現在有効になっているセキュリティルール上でfirestore.get()を使用している箇所があるとエラーになるので注意が必要です。
あるいは、IAMの整理をしている時に、間違ってこの作成されたサービスアカウントを削除してしまわないようにしましょう。


さいごに

これを機に、Storageのセキュリティルールも見直して、より安全なサービスを提供できるようにしましょう 🍵
2023年中には対応して、更なるセキュリティルールのアップデートに備えていきましょう 💪


References

https://zenn.dev/sgr_ksmt/articles/e6e7dfaf1851d1

https://zenn.dev/sgr_ksmt/books/2f83a604d636b241cf3c

https://firebase.blog/posts/2022/09/announcing-cross-service-security-rules

https://firebase.google.com/support/release-notes/security-rules

https://firebase.google.com/docs/rules/manage-deploy#manage_permissions_for_cross-service

Discussion

ログインするとコメントできます