🐕

Firestoreのセキュリティルールの運用を紹介します

2024/01/26に公開

こんにちは。
PharmaXでエンジニアをしている諸岡(@hakoten)です。

この記事の概要

PharmaXの薬局アプリケーションでは、患者のチャットなど一部のデータ管理にFirestoreを使用しています。
また、ユーザーの管理には、FirebaseAuth + Identity Platformによるマルチテナンシーを採用しており、各テナントのFirestoreに対して Firestoreのセキュリティルールでアクセスコントロールを行っています。

この記事では、薬局アプリケーションで実際に行っているFirestoreのセキュリティルール、単体テストの運用方法についてご紹介します。

Firestoreのセキュリティルールの基本的な書き方

簡単ではありますが、まずはセキュリティルールの基本について触れておきます。
セキュリティルールは、基本的には次のような独自の構文で定義されます。

service cloud.firestore {
  match /databases/{database}/documents {

    // citiesコレクション内のドキュメントに対してルールを定義する
    match /cities/{city} {
      // 読み込み可能な条件を condition に書く
      allow read: if <condition>;
      // 書き込み可能な条件を condition に書く
      allow write: if <condition>;
    }
  }
}

データベース内のどのドキュメントに対してルールを適用するのかを match で指定し、 allow 式を使って条件を定義するのが基本的な書き方になります。

関数

セキュリティルール内では、関数を定義することもできます。
たとえの次の関数は、「FirebaseAuth(Identity Platform)で認証が行われたユーザー」であるかをチェックする関数です。当然関数には、引数も渡すことができます。

// ---------------------------------------------
// サインしているかどうか?
// ---------------------------------------------
function isSignedIn() {
  return request.auth != null;
}

認証

前述にあった request.auth は、リクエストの情報を含むコンテキスト情報で、ルール内のどこからでも参照できます。

authでは、次のようなプロパティが参照可能です。

  • uid: Firebase Authユーザーのuid
  • token: Firebase AuthのJWTトークンの各種プロパティ

tokenにはJWTのトークンのクレームがmapとして参照できます。tokenに独自のカスタムクレームを付与している場合は、その情報も参照可能です。例えば次のような情報を参照することができます。

キー 説明
email アカウントに関連付けられている電子メール アドレス (存在する場合)。
phone_number アカウントに関連付けられている電話番号 (存在する場合)。
name ユーザーの表示名 (設定されている場合)。
firebase.tenant アカウントに関連付けられた tenantId (存在する場合)。例: tenant2-m6tyz

リソース情報

resourceというプロパティでは、接続対象のfirestoreのドキュメントへのアクセスが可能です。 resource.data.<プロパティ>でドキュメントのデータプロパティへアクセスすることができます。

service cloud.firestore {
  match /databases/{database}/documents {
    // 参照するドキュメントのvisibilityプロパティがpublicのときは読み込むことができる
    match /cities/{city} {
      allow read: if resource.data.visibility == 'public';
    }
  }
}

公式ドキュメント

詳しくは公式のドキュメントを参考ください。

https://firebase.google.com/docs/firestore/security/rules-structure?hl=ja
https://firebase.google.com/docs/firestore/security/rules-conditions?hl=ja

アクセスコントロールの運用

PharmaXのアプリケーションでは主に以下の観点でアクセスコントロールを行っています。

① テナント

Firestoreで管理しているチャットデータは、同一テナントのユーザーからのアクセス以外は許可しない設定になっています。

Identity Platformのテナントによるユーザー管理を行う場合、対象のテナントIDを request.auth.token.firebase.tenant というコンテキスト情報から取得することができます。

PharmaXでは、次のような関数定義をして、各ドキュメントへの参照コントロールを行っています。

(アクセス例)

function isPharmaXTenant() {
  return request.auth.token.firebase.tenant != null && 
    request.auth.token.firebase.tenant == '<PharmaXテナントのID>';
}

match /pharmaxRooms/{document=**} {
  // pharmaXのテナントのユーザー以外は読み取ることができない。
  allow read: if isPharmaXTenant();
}

② ロール

薬局アプリケーションは、薬局の機能を使う「患者」と、チャットを始めとした薬局業務を行う「薬剤師」でユーザーのロールが分かれています。ロールごとに参照や書き込みができる情報が異なるため、ロールによるアクセスコントロールも行っています。

ロールプロパティは、Identity Platformのユーザーのトークン(JWT)作成時にaccountCategoryというカスタムクレームを設定することで実現しています。設定したaccountCategoryを次のようにチェックしています。

(アクセス例)

// ---------------------------------------------
// 薬剤師のアクセス権限
// ---------------------------------------------
function canPharmacistAccess() {
  return request.auth.token.accountCategory == 'pharmacist';
}

match /pharmaxRooms/{document=**} {
  // 書き込みは薬剤師ロールでないと行えない
  allow create: if isPharmaXTenant() && canPharmacistAccess();
}

③ 自分のドキュメントかどうか

患者側のチャットデータについては、患者自身のデータしか参照できないように、「ドキュメントが自分のものかどうか」のチェックを行っています。

具体的には、チャットルームのドキュメントIDをIdentity PlatformのユーザーIDとして作成し、自分のリソースかどうかチェックしています。

(アクセス例)

// ---------------------------------------------
// 自分のリソースかどうか?
// ---------------------------------------------
function isOwnResource(documentId) {
  return request.auth.uid == documentId;
}

// ---------------------------------------------
// 患者のアクセス権限
// ---------------------------------------------
function canPatientAccess(documentRootId) {
  return request.auth.token.accountCategory == 'patient' && isOwnResource(documentRootId);
}

match /pharmaxRooms/{documentRootId}/{document=**} {
  // 患者は自分のルームでなければ、参照することができない
  allow read: if isPharmaXTenant() && canPatientAccess(documentRootId);
}

単体テストの書き方

作成したセキュリティルールのテストは、 Jest + firebase エミュレータで行っています。

セキュリティルールの単体テストを書くためには @firebase/rules-unit-testing というモジュールが用意されていて、各種ユーティリティ関数を使用することで、単体テストベースでセキュリティルールのテストを書くことができます。

https://www.npmjs.com/package/@firebase/rules-unit-testing

テスト環境の初期化

@firebase/rules-unit-testingで提供されている RulesTestEnvironmentinitializeTestEnvironment を使ってエミュレータのテスト環境を初期化します。

import {
  initializeTestEnvironment,
  RulesTestEnvironment,
} from '@firebase/rules-unit-testing';

describe('firestore.rules', () => {
  // 単体テスト用の環境設定
  let testEnv: RulesTestEnvironment;

  afterAll(async () => {
    await testEnv.cleanup();
  });
  
  // テスト前に環境の初期化を行う
  beforeAll(async () => {
    testEnv = await initializeTestEnvironment({
      projectId: '<firebase projectID>',
      firestore: {
        // ルールファイルは環境ごとに実際に適用しているものをセットする
        rules: fs.readFileSync(
          './path/to/firestore.dev.rules',
          'utf8',
        ),
        port: 8080,
        host: '127.0.0.1',
      },
    });
  });

テストケース

実際のテストケースは次のようになります。
テスト結果は、assertSucceeds, assertFails というアサーション関数が提供されているため、これを使います。

認証されたユーザーのコンテキストは authenticatedContext というユーティリティ関数を使って作成します。リクエストから参照できるプロパティは、引数に渡します。


import {
  assertFails,
  assertSucceeds,
} from '@firebase/rules-unit-testing';

it('accountCategoryがpatientの場合は、ドキュメントの読み込みができること', async () => {
  // テナントは、型が提供されていないため、anyでセットする
  const tenantData = { firebase: { tenant: tenantId } } as any;
  // authenticatedContextの引数には、コンテキストのプロパティがセットできる。
  const context = testEnv.authenticatedContext('user_id', {
    accountCategory: 'patient',
    ...tenantData,
  });
  // テスト用のドキュメントを書き込みテスト準備を行う。
  const testQuery = query(
    collection(context.firestore(), 'collection_name', 'document_id', 'test_message'),
  );
  
  // 読み込みが成功したかをassertSucceedsで検証する
  await assertSucceeds(getDocs(testQuery));
});

※ 実際は、authenticatedContextをラップしてテストデータを作りやすい内部のユーティリティを使ってテストデータは作成しています。

公式ドキュメント

詳しくは、こちらのドキュメントも参照ください。

https://firebase.google.com/docs/firestore/security/test-rules-emulator?hl=ja#run_local_unit_tests

テストの実行

テストの実行は、firebase emulators:exec コマンドを使い、CLIでエミュレータを立ち上げて実行しています。 emulators:exec は、引数で渡したコマンドが実行される前にエミュレータを立ち上げて、実行後にエミュレータをシャットダウンしてくれるため、CLIでの実行では非常に便利なコマンドです。

firebase emulators:exec --only firestore 'jest --silent --config jest.config.firebase.js --detectOpenHandles --forceExit'

CIでのテスト実行

セキュリティルールが更新したときのプルリクエスト作成時にgithub actionsによるセキュリティルールのテストを行っています。

ルールの更新は、そこまで高頻度で発生しないため、ルールファイルが配置されたディレクトリのファイル更新が発生したときのみテストが実行されるように、以下のような設定になっています。

name: Verify firebase security rules
on:
  pull_request:
    branches:
      - '**'
    paths:
      # ルールファイルの置いてあるディレクトリのみを対象にトリガーする
      - 'firebase/securityRules/**'
  push:
    branches:
      - main
      - develop
    paths:
      - 'firebase/securityRules/**'
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    steps:
      - name: checkout
        uses: actions/checkout@v4
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'yarn'
      - name: 'Yarn install'
        run: |-
          yarn install
      - name: 'Exec test: stg'
        run: |-
	  # 環境ごとのテストを実行
          yarn test:firebase:stg

終わりに

以上、PharmaXの薬局アプリケーションでの、Firestoreのセキュリティルール運用についてご紹介しました。少しでも皆さんの参考になれば幸いです。

PharmaXでは、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。
もし、興味をお持ちの場合は、私のXアカウント(@hakoten)や記事のコメントにお気軽にメッセージいただけますと幸いです。まずはカジュアルにお話できれば嬉しいです!

PharmaXテックブログ

Discussion