"7 tips on Firebase security rules and the Admin SDK"を解説してみる

## tl;dr
[The Firebase Blog: 7 tips on Firebase security rules and the Admin SDK](https://firebase.googleblog.com/2019/03/firebase-security-rules-admin-sdk-tips.html)という記事を日本語で解説してみる。
随所に個人の意見もちりばめてありますが、あしがからず。

## はじめに
[FirebaseのOfficial blog](https://firebase.googleblog.com)でSecurity rulesについて、いくつかTipsが紹介されました。
とても有益な記事なのでできる範囲で紹介・解説してみたいと思います。[^1]

[^1]: Security rulesはFirestore, Realtime Database, Cloud Storageについてそれぞれ設定できますが、自分がFirestoreをメインで使用しているので、Firestoreメインで解説します。

## Tip \#1: Admin SDK bypasses security rules

Admin SDK経由のアクセスについての紹介です。
[Firebase Admin SDK](https://firebase.google.com/docs/admin/setup)を使うとSecurity rulesを回避してFirestoreのデータに触れます。[^2]
Admin SDKの認証はIAMの[Service Account](https://cloud.google.com/iam/docs/understanding-service-accounts)を使用することになります。
「[サーバーに Firebase Admin SDK を追加する](https://firebase.google.com/docs/admin/setup#add_firebase_to_your_app)」に書かれているように、FirebaseコンソールからService Accountのkey(JSONファイル)をダウンロードすることで使えるようになります。[^3]
Admin SDKはService Accountの権限で実行されるので、信頼できる環境に配置するように喚起されており、代表的な環境として、Cloud Functions, Google App Engine(GAE)があげられています。

ちなみにCloud FunctionsやGAEだと[Application Default Credential](https://cloud.google.com/docs/authentication/production)(ADC)が使えるので、Service Accountのkey管理をせずに済むというメリットがあります。
もしADCで403などが発生した場合は、GAEのデフォルトService Accountの権限を確認するとよいでしょう。Cloud FunctionsはデフォルトだとGAEと同じService Accountで実行されますが、オプションでService Accountを指定することもできるようになっています。

[^2]: Realtime DBだとSDKのオプションで`uid`を上書きできるとありますが、RTDB詳しくないので割愛します。
[^3]: GCPコンソールのIAMから上記のService Accountのroleをみると`Editor`がついているので結構強めの権限が振られていますが、いまだと`Firebase Admin`や`Cloud Firestore Editor`で十分なケースはありそうです。

## Tip \#2: Make certain data read-only

これはクライアントから直接更新させたくないようなある特定のデータについて、Cloud Functions経由で更新するパターンについてです。
Tip \#1の内容がここで生きてきます。

例として挙げられているのは、StackOverFlowのようなフォーラムシステムにおけるユーザ投稿のスコアリング部分についてです。

```javascript
service cloud.firestore {
  match /databases/{database}/documents {
    function isAuthorized() {
      // Some function that grants users read access.
    }

    match /scores/{uid}/{document} {
     allow write: if false; // Nothing gets past me (except Admin of course).
     allow read: if isAuthorized(); // Grant read access as necessary.
    }
  }
}
```

上記は`/scores/{uid}/{document}`についてreadのみを許可しています。
scoreについては以下のAdmin SDKを使ってCloud Function経由で更新します。

```javascript
import * as admin from 'firebase-admin';

admin.initializeApp();

export const updateScores = functions.firestore.document('posts/{userId}/{postId}')
  .onCreate((snapshot, context) => {
    const score = calculateScore(snapshot);
    const userId = context.params.userId;
    const doc = admin.firestore().collection('scores').document(userId);
    return admin.firestore().runTransaction((txn) => {
      return txn.get(doc).then((snap) => {
        const current = snap.data().total || 0;
        txn.set(doc, {total: current + score}, {merge: true});
      });
    });
  });
```

この関数は`posts/{userId}/{postId}`が作成されたときに実行されます。
`calculateScore`の実装は省略されてますが、なんらかのロジックを実装して、算出されたスコアで加算するようになっています。[^3]
`scores` CollectionはSecurity rules上はread-onlyですが、Tip \#1であったようにAdmin SDKはこれを回避できるので更新できるようになっています。
もちろんこのロジックをクライアントサイドで実装することで、同一トランザクションでフォーラム投稿時にスコアを更新することも可能ですが、その場合悪意あるユーザがスコアを改竄可能になるリスクが生じます。
しかし、このパターンのようにCloud Functionsを経由することで、ユーザからの更新は制限しつつ、安全にスコアを管理できるようになります。

[^3]: ブログでは特に言及がないですが、Admin SDKの認証はADCでされています。

## Tip \#3: Role-based access control with custom claims

[Custom Claims](https://firebase.google.com/docs/auth/admin/custom-claims)を使った権限管理のパターンについてです。
MOOC (Massively Open Online Courses)を例にロールベースの権限管理をどうやって実現するか?というトピックを扱っています。

登場人物は以下の3つです。

1. 生徒
2. 教師
3. アシスタント

教師とアシスタントは受講コース教材を閲覧と更新の両方が可能ですが、生徒は閲覧のみ可能であるとします。
これらの役割をCustom Claimsを付与することで管理します。

Custom ClaimsはFirebase Authが返すIDトークンに任意の属性を付与できる機能で、Admin SDKで設定できます。

```javascript
import * as admin from 'firebase-admin';

admin.initializeApp();

async function grantTeacherRole(userId: string) {
  await admin.auth().setCustomUserClaims(userId, {role: 'teacher'});
}
```

この`grantTeacherRole`を使うことで任意のユーザに`tearcher`ロールを付与できます。
この関数をどの環境で動かすのかは元記事には言及がありませんが、以下のドキュメントにはいくつかユースケースが記載されています。
https://firebase.google.com/docs/auth/admin/custom-claims#examples_and_use_cases

Custom ClaimsでRole-based Access Control(RBAC)を実現するSecurity Rulesは以下です。

```javascript
service cloud.firestore {
  match /databases/{database}/documents {
    function isTeacher() {
      return request.auth.token.role == "teacher";
    }

    function isTA() {
      return request.auth.token.role == "ta";
    }

    match /courses/{doc} {
      allow write: if isTeacher() || isTA(); // Only teachers and TAs can write.
      allow read: if true; // But anybody can read.
    }
  }
}
```

`/courses/{doc}`に対して教師とアシスタントだけ書き込めるようになっています。
`request.auth.token.role`でCustom Claimsに設定されたロールを取得可能なことが分かります。

> Note that Firebase client SDKs cache ID tokens up to an hour. Therefore changes to a user's custom claims may take up to an hour to take effect.

これは注意が必要です。
IDトークンが1時間はキャッシュされるので直ぐにはCustom Claimsの更新が反映されるわけではないという点です。
なので、前述のユースケースのドキュメントには別途リフレッシュ用のコレクションを用意し、その値をログイン時にサブスクライブすることで即時反映をやっています。
ただ、即時反映したいならCustom Claims使わずに、権限管理用のコレクションをFirestoreに用意して、Security Rulesでwriteを塞いだ上でAdmin SDKで更新すればいいのでは?と思います。[^4]

[^4]: 実際私がいま実装しているアプリではCustom Claimsは使っていません。

## Tip \#4: Temporarily withhold sensitive data from users

なんらかセンシティブなデータについて、ユーザへの表示を一時的に保留にするパターンです。

例として再びMOOCを考えます。
教師が全部のテストの採点が終わるまで、生徒の採点結果を表示させないようにしたい、というケースです。
先に記載されているSecurity Rulesをみてみます。

```javascript
service cloud.firestore {
  match /databases/{database}/documents {
    function isPublic() {
      return resource.data.visibility == "public";
    }

    match /grades/{document} {
     allow read: if isPublic(); // Cannot read unless marked as "public".
     allow write: if false; // Nobody except Admin can update the documents.
    }
  }
}
```

ポイントは`/grades/{document}`のreadです。
`resource.data.visibility`をみて認可するようにしています。
この`visibility`をAdmin SDKで編集するようにして、表示・非表示を管理します。
バックエンドのコードは以下です。[^5]

[^5]: そういえばしれっと`async/await`が使われてるのでNode8以上じゃないと動かないですね。

```javascript
import * as admin from 'firebase-admin';

admin.initializeApp();

async function gradeTests() {
  // Create a new document and continue to write to it.
  const doc = await createNewDoc();
  await updateGrades(doc);

  // Later, make the document visible when ready.
  await releaseGrades(doc);
}

async function createNewDoc() {
  const doc = admin.firestore().collection('grades').document();
  // Make the new doc hidden by default
  await doc.set({visibility: 'private'});
  return doc;
}

async function releaseGrades(doc) {
  await doc.update({visibility: 'public'});
}
```

`updateGrades`の実装書かれてないですが、`createNewDoc`ではprivateで作って、なんらか採点が終わったらpublicに更新する、というようなコードになっています。
ちょっと雑な気もしますが。

元記事にはなにも書かれてないのですが気になった点として、`/grades`に対してQueryした場合の挙動です。
`db.collection('grades')`だけで、`visibility=public`のものだけを返すのでしょうか?
それとも`.where('visibility', '==', 'public')`をしないとエラーになったりするのでしょうか?
試せてないのでご存知の方がいらっしゃればコメントもらえるとありがたいです。

## Tip \#5: Minimize the rules that apply to a document

ドキュメントに適用するルールを最小化しましょう。
Security Rulesのオーバライドに関するハマりポイントの解説です。

以下のRulesをみてみます。

```javascript
service cloud.firestore {
  match /databases/{database}/documents {
    match /reports/{document} {
     // This rule is intended to selectively grant users read-only access to the
     // documents in the 'reports' collection. But the rule below inadvertently
     // grants teachers read-write access to these documents.
     allow read: if isConditionMet();
     allow write: if false;
    }

    match /{document=**} {
     // This rule matches all documents in the database, including the 'reports'
     // collection. In case of teachers, this will override the previous rule.
     allow read, write: if isTeacher();
    }
  }
}
```

最初のmatchブロックでwriteは不可、readは任意のユーザにだけ可としています。
しかし`isTearcher`だとこのルールは上書かれてしまい、`/reports`以下全部に対してread/write可能になります。
これは2番目のmatchブロックによるものですが、複数matchする場合、1つでも許可されるとそちらが優先されてしまうためです。

誤ってアプリ内の保護されたデータへのアクセスをユーザーに許可しないようにするには、各ドキュメントが1つのルールステートメントにのみ一致するようにルールを記述するように注意する必要があります。
なので`{document=**}`のようなワイルドカードの使用は極力避けるべきです。
どうしても必要になった場合は、Admin SDKを使うとSecurity Rulesを回避できます。

と書いてありますが、私がいま作っているアプリではreadだけですがワイルドカードを使用しています。
履歴用のコレクションなのですが、ワイルドカードを使わないと難しかった[^6]です。(設計が悪いのかもしれませんが)

[^6]: 詳しく聞きたい方がいればコメントで聞いてください。

## Tip \#6: Develop administrative tools using the Admin SDK

管理ツールがなんらか必要になったらAdmin SDKを使おうね、という話です。

データのバックアップや不要になったユーザデータの削除などが必要になった場合、データの大部分に無制限にアクセスできる必要がでてきます。
このような場合、Security Rulesを緩めて対応するのではなく、Admin SDKを使って管理ツールを実装すべきです。
そうすることで、管理ツールはすべてのデータへのフルアクセスを維持しながら、エンドユーザーがアプリでできることを細かく調整できます。

## Tip \#7: Implement dynamic access control lists

動的アクセスコントロールのパターンです。

例としてはフォーラム内のブラックリスト管理が紹介されています。
スパムと思わしきユーザが見つかった場合に、本当にスパムかどうかなんらか調査が完了するまで一時的にフォーラムへの投稿を制限したいとします。
この場合、簡単なアクセスコントロールリスト(ACL)をFirestoreに用意して、Admin SDK経由で動的に管理できます。
ACLを使ったSecurity Rulesのサンプルは以下です。

```javascript
service cloud.firestore {
  match /databases/{database}/documents {
    function isBlackListed() {
      return exists(/databases/$(database)/documents/blacklist/$(request.auth.uid))
    }

    // Collections are closed for reads and writes by default. This match block
    // is included for clarity.
    match /blacklist/{entry} {
      allow read: if false;
      allow write: if false;
    }

    match /posts/{postId} {
      allow write: if !isBlackListed()
    }
  }
}
```

`blacklist`というコレクションに`uid`が存在しているユーザには書き込みを制限しています。
組み込み`exists`関数を使うことで`blacklist`に存在しているかどうかを確認できます。
また`blacklist`コレクションへのread/writeを明示的に不可にしています。

特定ユーザお`blacklist`に追加して、書き込み不可にするコードは以下です。

```javascript
await revokeWriteAccess('bAdAgEnT');

async function revokeWriteAccess(userId) {
  const user = admin.firestore().collection('blacklist').document(userId)
  await user.set({
    reason: 'possible bad agent',
    blacklisted_at: admin.firestore.FieldValue.serverTimestamp(),
  });
}
```

前述のSecurity Rulesがあるので、`blacklist`への更新はこのバックエンドコードでのみ可能です。
このACLへの変更は即座に反映されます。
また制限解除したい場合は`blacklist`から該当ユーザを削除すれば完了です。

最後に時限式で制限解除するSecurity Rulesが記載されています。

```javascript

service cloud.firestore {
  function isTwoDaysElapsed() {
    return request.time > timestamp.value(get(/databases/$(database)/documents/
         blacklist/$(request.auth.uid)).data.blacklisted_at.seconds*1000) +
         duration.value(2, 'd');
  }

  match /databases/{database}/documents {
    match /posts/{postId} {
        // allow if blacklisted more than 2 days ago
       allow write: if isTwoDaysElapsed();
    }
  }
}
```

`blacklist`に追加されたタイムスタンプが`blacklisted_at`に保存されているので、その値と現在時刻とを比較することで実現しています。
サンプルでは二日経つと自動で制限が解除されるようになっています。組み込み関数の[timestamp.value](https://firebase.google.com/docs/reference/rules/rules.timestamp_#.value), [get](https://firebase.google.com/docs/reference/rules/rules.firestore#.get), [duration.value](https://firebase.google.com/docs/reference/rules/rules.duration_#.value)が使われていますね。

括弧の入れ子が複雑で可読性が低いのと、要件としてセンシティブなので以下の記事を参考にテストを書くのをお勧めします。
https://firebase.google.com/docs/firestore/security/test-rules-emulator
https://techlife.cookpad.com/entry/2018/08/16/100000

## まとめ

アプリケーションコードとSecurity Rulesとが分離されていることのメリットが書かれています。

- アプリ側の実装をシンプルに保ちつつルールを更新できる
- そのルール更新の際もアプリをデプロイすることなく実施可能

これはその通りだと思います。
いまはシミュレータを使うことでUnit Testも書けるようになったのでDeveloper Experience(DX)も向上しています。

またAdmin SDKを用いてバックエンドのコードを書けば、無制限にアクセス可能なので、高度な要件にも対応可能です。
とはいえ、使いすぎると前述のメリットが失われるので必要最小限にしておくのがいいんじゃないかと思います。

Happy coding with Firebase!