Firestore のデータを TypeScript と Security Rules で安全に扱う話
- TAG : Advent Calendar | Firebase | Firestore | React | Refeed | Tech & Science | TypeScript | トチカチ | フロントエンド
- POSTED : 2020.12.16 08:22
f t p h l
この記事は GiXo アドベントカレンダー の 16 日目の記事です。
昨日は、TableauのLOD表現で注意すべきことでした。
MLOps Div. の堀越です。突然ですがみなさん Firestore は好きですか?私は好きです。ときどき「安定してない」、「パフォーマンスにムラがある」、「モジュールでかすぎ」といった苦言も見かけますが、弊社のように少人数で開発を行うチームにとってフルマネージドの Firestore はサービス開発に欠かせない存在となっています。なお、先日のMLOps Div. 紹介記事や弊社のテックリード採用ページに Firebase が載っているように、MLOps Div. で開発するサービスには Firestore を採用するケースが多いです(トチカチや Refeed)。本記事では、そんな Firestore のデータをフロントエンドで安全に扱うために検討したことをご紹介します。(採用ページでご興味もっていただけた方はお気軽にお問い合わせください。カジュアル面談も行っています)
目次
背景
Firestore は便利なサービスですが、TypeScript と一緒に開発する際のデータの扱いで改善しておきたいと思うことがありました。それは下記の 2 つです。
- フロントエンドが Firestore から取得したデータには型がついていない
- Firestore のデータは型やプロパティを無視して上書きできてしまう
Firestore のフィールドそのものには型がついていますが、フロントエンドでそのデータを取得する際には型がついてこないため、必要なら TypeScript で人為的に型を付与してあげる必要があります。しかし型システムによる推論でなく人為的に付ける型の場合、TypeScript の型システムに予期せぬ実行時エラーを持ち込む可能性が常にあります(Firestore 側で型が変わっていてもそれを無視して異なる型を付けてしまうとか)。
このエラーへの不安は Firestore のドキュメントフィールドにおけるプロパティ数に比例して増加していきます。「このデータ、本当に xx プロパティを持ってるの?」「このプロパティの型を string 前提で処理して大丈夫?」といった具合です。
またフロントエンド から Firestore へデータの書き込みを行う際も、デフォルトではプロパティ名や型を無視してデータを書き込むことができてしまいます。もちろんそれがドキュメント指向 NoSQL である Firestore の強みでもあるのですが、どのデータモデルについても予期せぬデータが書き込まれてしまう可能性があるというのは精神衛生上あまりよろしくありません。
ということで、上記2つの課題をフロントエンド開発において許容したくない場合に、TypeScript と Security Rules を活用してどう改善できるか検討した内容を書いていきます。
フロントエンドが Firestore から取得したデータには型がついていない
まずは Firestore からデータを取得した際に型がついていないとどのような問題があるかをご紹介し、それに対する解決策を書いていきます。
問題点
TypeScript では下記のように Firestore からデータを取得します。
1 2 3 4 5 6 |
const doc = await firebase .firestore() .collection('/users') .doc('hoge') .get(); const data = doc.data(); // data の型は実質 unknown |
最終的に取得した data
の型は実質 unknown
状態であるため適切な型がついていません。この場合、TypeScript では 型アサーション as
によって型情報を強制的に上書きできるため下記のように型を付けることが可能です。(any
は論外としている)
1 2 3 4 5 6 7 |
type User = { userId: string; age: number; gender: 'male' | 'female' | 'unknown'; }; const data = doc.data() as User; // data: User |
Firestore は約1年前にトチカチで使い始めましたが、上記のように as
で型を付けて開発を進めていました。しかし型アサーションは実際のデータ型を無視して強制的に型を上書きできてしまうため、Firestore から取得したデータが異なる型であってもそれをアサーションされた型として扱ってしまいます。
仮に age
というプロパティが number
型から string
型に変わっても、TypeScript の型アサーションで User
が付与されていたら age
を number
型として扱い、gender
プロパティがなくなっても gender
プロパティが存在するものとして扱います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const doc = await firebase .firestore() .collection('/users') .doc('hoge') .get(); const data = doc.data(); // data の型は実質 unknown type User = { userId: string; age: number; gender: 'male' | 'female' | 'unknown'; }; if (doc.exists){ // data の中身が { userId: 'abc', age: '10歳' } だったとしても User 型を付与できてしまう const data = doc.data() as User; // data: User data.age; // TypeScript 上では number として扱われるが実行時は string data.gender; // TypeScript 上では Union 型として扱われるが実行時は undefined // 下記のような処理があると実行時エラー data.gender.length; // Uncaught TypeError: Cannot read property 'length' of undefined } |
上記のような予期せぬエラーを避けるため、型アサーションによる型付けは可能な限り避けることが望ましいです。しかし Firestore から取得したデータについても型を付けなければ TypeScript での開発は捗りません。異なる型のデータを取得した場合の対策として Type Guard を使い守ることは可能ですが、データをやりとりする過程で明示的に不正なデータをはじけるようにしたいところです。
解決策:Convert 層で Firestore とやりとりするデータ型を保証する
そんな型アサーションへの不安を払拭してくれる機能が FirestoreDataConverter
です。この機能はつい最近の開発案件で大きめの Firestore ドキュメントフィールドを扱うことになり、型エラーへの不安が生まれて調査する中で知りました。(機能自体は 2020 年 1 月に追加されて 7 月には安定稼働していた)
使い方はシンプルです。Firestore モジュールの get()
や set()
を実行する際、メソッドチェーンに withConverter()
を挟んで任意の変換処理を追加するだけです。これにより自分で型アサーションを行わずとも doc.data()
に型が付きます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// 公式 Example class Post { constructor(readonly title: string, readonly author: string) {} toString(): string { return this.title + ', by ' + this.author; } } const postConverter = { toFirestore(post: Post): firebase.firestore.DocumentData { return {title: post.title, author: post.author}; }, fromFirestore( snapshot: firebase.firestore.QueryDocumentSnapshot, options: firebase.firestore.SnapshotOptions ): Post { const data = snapshot.data(options)!; return new Post(data.title, data.author); } }; const postSnap = await firebase.firestore() .collection('posts') .withConverter(postConverter) .doc().get(); const post = postSnap.data(); if (post !== undefined) { post.title; // string post.toString(); // Should be defined post.someNonExistentProperty; // TS error } |
withConverter()
のインターフェースは下記のようになっています。
1 2 3 4 5 |
export interface FirestoreDataConverter<T> { toFirestore(modelObject: T): DocumentData; toFirestore(modelObject: Partial<T>, options: SetOptions): DocumentData; fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): T; } |
Firestore とデータのやりとりをする過程に TypeScript による変換処理を施す Convert 層を設けることで、フロントエンド が意図しないデータの入出力を防ぐことができます。
念のため補足しておくと、FirestoreDataConverter
は Firestore から取得したデータに自動で正しい型を付けてくれるわけではありません。あくまで詳細は自分で実装するのですが、その実装をインターフェースが決まった関数で書くことができ、残りは withConverter()
がよろしくやってくれることがメリットかなと思います。個人的には withConverter()
というデータ処理を行う変換層を明示的に書けるのがよいです。linter で withConverter()
を強制できれば型アサーションによる実装を書いてしまうことも防げます。(そうした lint ルールは現状なさそうなため、自作の必要がありますが)
意図せぬ型の受け渡しを防ぎたい場合はこの Convert 層に TypeScript の Type Guard を追加しましょう。Firestore とやりとりするデータに関するエラーは Convert 層で検知できるため、バグの切り分けやエラーハンドリングも容易になりますね。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
type Gender = 'male' | 'female' | 'unknown'; type User = { userId: string; age: number; gender: Gender; }; // バリデーション const validateUserType = (data: any): data is User => { if (!(data.userId && typeof data.userId === 'string')) { return false; } if (!(data.age && typeof data.age === 'number')) { return false; } const genderList: Gender[] = ['male', 'female', 'unknown']; if (!(data.gender && genderList.some((gender) => data.gender === gender))) { return false; } return true; }; // Convert を実行する関数 const userConverter = { toFirestore(user: User): DocumentData { return { userId: user.userId, age: user.age, gender: user.gender }; }, fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): User { const data = snapshot.data(options)!; if (!validateUserType(data)){ console.error(data); throw new Error('invalid data'); } return { userId: data.userId, age: data.age, gender: data.gender, }; }, }; const doc = await firebase .firestore() .collection('/users') .withConverter(userConverter) .doc('hoge') .get(); if (doc.exists){ // Convert 層をパスしているため型が付与されている const user = doc.data(); // user: User doc.age; // number doc.gender; // Gender } |
これで Firestore の読み込み・書き込みも型安全に行えるようになりました。
Firestore のデータは型やプロパティを無視して上書きできてしまう
次は Firestore では型やプロパティを無視してデータの上書きができてしまう問題についてです。これはデフォルトの仕様なため嫌ならそもそも Firestore を使わなければよいのですが、自分の場合は「デフォルトはそれでよいけど制限したいときもある」といった感じです。
問題点
型やプロパティを無視して上書きできるというのは下記のようなケースを指します(通常の書き込み操作です)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
const newUser: User = { userId: 'newUser', age: 30, gender: 'male', }; // newUser を 'hoge' に書き込む await firebase .firestore() .collection('/users') .doc('hoge') .set(newUser); // success // 異なる型の Dog で 'hoge' を上書きできる type Dog = { name: string; age: number; }; const newDog: Dog = { name: 'Pochi', age: 3, }; // newDog を 'hoge' に書き込む await firebase .firestore() .collection('/users') .doc('hoge') .set(newDog); // success |
スキーマを気にせず書き込んでいけること自体は、開発当初の段階ではサクサク実装を進めることができ、変更にも柔軟な対応ができるためありがたいです。しかし運用フェーズに差しかかると不安のタネにもなります。
全く予期せぬデータを書き込んでしまったというやらかしが発生したことはありませんが、予防できるに越したことはありません。データの読み込み・書き込み操作に Convert 層を挟むことで型安全にはできましたが、withConverter()
を使った実装が徹底されていなければ予期せぬデータの書き込みは発生してしまいます。また withConverter()
を使えば安全というわけではないため、実装にミスがあれば元も子もありません。
解決策:Security Rules でデータに対するバリデーションをかける
この問題に対しては Firestore の Security Rules を使って防衛線を張っておくことが可能です。Security Rules では firestore.rules に任意のルールを設定しておくことで、データの書き込みを行う際にバリデーションをかけることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// firestore.rules function validateUser(user) { return user.userId.size() == 33 // userId は33文字 && 0 <= user.age && user.age <= 120 // age は0〜120の数値 && user.gender.matches('male|female|unknown') // gender は 'male', 'female', 'unknown' のいずれか } match /databases/{database}/documents { match /users/{userId} { allow read: if request.auth.uid != null; allow write: if validateUser(request.resource.data); // 条件を満たさないデータは書き込めない } } |
一例ですが firestore.rules に上記のようなバリデーションを設定しておくと、フロントエンドから誤ったユーザーデータの書き込みをリクエストしても Firestore はエラーではじいてくれます(userId
が 33 文字でなければエラー、age
が string や null
ならエラー)。前述の FirestoreDataConverter
を使用していなかったりそれだけでは不安だったりする場合は、上記のようなバリデーションを追加することでより安全にデータを扱えるでしょう。
とはいっても全てのデータモデルについて TypeScript と Security Rules で二重にバリデーションをかけるのは骨が折れます。管理も煩雑になるかもしれません。そんなときは必要最低限 Convert 層を通ったデータであるか否かのみを確認するといったことも可能です。
例えば FieldValue.serverTimestamp()
を値とする convertedAt
といったプロパティを Convert 層で付与しておくと「10 秒以内に Convert されたオブジェクトであるか」のような条件を firestore.rules に追加し書き込み制限できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// TypeScript const userConverter = { toFirestore(user: User): DocumentData { return { userId: user.userId, age: user.age, gender: user.gender, convertedAt: firebase.firestore.FieldValue.serverTimestamp(), // Convert 層で Timestamp を付与する }; }, fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): User { // 省略 }, }; |
1 2 3 4 5 6 7 8 9 |
// firestore.rules match /databases/{database}/documents { match /users/{userId} { allow read: if request.auth.uid != null; // リクエストされたデータの `convertedAt` プロパティの Timestamp がリクエストから10秒以内であれば書き込みを許可する allow write: if request.resource.data.convertedAt + duration.value(10, 's') >= request.time; } } |
全てのプロパティにバリデーションをかけずとも一部のプロパティが正しい値であるか否かをチェックしておくだけで、全く予期しないデータを書き込んでしまう事態は大体防げるでしょう。(大体でよいかはさておき)
ただしサーバーサイド SDK からの書き込みには Security Rules が効かないため、これはあくまでクライアントサイドからの書き込みに対する制限です。
おわりに
本記事では Firestore のデータを TypeScript と Security Rules で安全に扱う方法をご紹介しました。今回は「予期せぬデータが Firestore から渡されても検知できるようにする」、「予期せぬデータを Firestore に書き込むことを防ぐ」といった観点で対策を検討しましたが、実装が意図した通りに動いているかを確認する上では Firestore エミュレーターを使ってこれらをテストするというのも重要だったな、、と書き終えた後に思いました。 @firebase/test
を使ったテストについてもいずれ追記できたらよい、、ですね。
2020 年は Firestore から取得したデータによる予期せぬエラーに何度か遭遇してしまいましたが、2021 年は安心してデータをやり取りすることができそうです。
明日は Technology Div. の奥田より「クラウドデータ基盤をTerraformを使ってIaC化 & 量産する」を公開予定です。
Go Horikoshi
MLOps Div. Lead / Kaggle Master
React・TypeScript を中心としたフロントエンド技術や、機械学習・データ分析を活用したサービス開発について発信していきます。
f t p h l