はじめに
Firebase Cloud FirestoreはNoSQLのドキュメントDBで、簡単にデータのCRUDができて便利だが、セキュリティ ルール言語に書かれているような内容をしっかり理解してルールを設定しないと、他のユーザーからデータが見えてしまった、みたいなセキュリティ事故が起きてしまう。
そこで今回はローカル環境でFirestoreをエミュレーションしてくれるFirebase Local Emulator Suiteを利用して、セキュリティルールの適用状況や、ルールの記述に使える記法などについて理解を深めてみようと思う。
最終的には、以下のようなユースケースを満たすルールを構築してみたいと思う。
- グループ管理機能
- アプリ内でユーザーは自由にグループを作成できる
- 同じグループに属するユーザーは他のユーザー情報を参照できる
- グループの情報を取得・更新できるのは、グループに属するメンバーのみ
Firebase Local Emulator Suiteについて
FirebaseにあるHostingやFunctions、Firestoreなどの各サービスをローカル環境でエミュレーションしてくれるツール。firebase init emulators
で以下のように初期化できる(firebaseコマンドを実行するためのCLIインストール方法は、CLI を設定または更新するを参照)。
上記のセットアップを行うと、firebae.json
にエミュレーターの設定が記載される。
{
...
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8081
},
"ui": {
"enabled": true
},
"singleProjectMode": true,
"auth": {
"port": 9099
},
"hosting": {
"port": 5000
}
}
}
エミュレーターの初期セットアップができたら、firebase emulators:start
コマンドでエミュレーターを実行でき、ブラウザ上でエミュレーターの画面を開く事ができるようになる(以下の画像ではCloud Functionsなど別のエミュレーターも立っているが、AuthenticationとFirestore以外は今回使用しないので設定がなくて問題ない)。
自分のアプリからエミュレーターに接続する
以下のように、connectFirestoreEmulator
関数でどのホスト・ポートにつなぐかを設定でき、これでローカル環境のエミュレーターにSDKのリクエストは飛ぶようになる(Authenticationの方はconnectAuthEmulator
になる)。
import { initializeApp } from 'firebase/app';
import { getAuth, connectAuthEmulator } from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
if (import.meta.env.MODE === 'localdev')
connectAuthEmulator(auth, 'http://localhost:9099');
const db = getFirestore(app);
if (import.meta.env.MODE === 'localdev')
connectFirestoreEmulator(db, 'localhost', 8081);
export { auth, db };
ここまでが事前準備になる。
Firestoreのセキュリティールールで使える変数の値を見てみる
ルールの全容をここでは紹介するのではなく、条件の作成に書かれている、ルールを作成する際に利用できる変数について、具体的にどのような値になるのか?分かりにくい部分があると思ったので、そこについてローカル環境のエミュレーターで動作確認をしつつ、整理してみたいと思う。
ルールの全容については、以下の公式ドキュメントを参照。
request 変数
request.auth
request.auth
は以下のようなキー・バリューになる。
- request.auth
- token
- aud:fir-cloud-functions-trial
- auth_time:1679737160
- email:panda.otter.157@example.com
- email_verified:true
- exp:1679740760
- firebase
- identities
- google.com
- 0:6483494143492003001624369540996272856892
- sign_in_provider:google.com
- identities
- iat:1679737160
- iss:https://securetoken.google.com/fir-cloud-functions-trial
- name:Panda Otter
- sub:zVt409cKNzfjXIAUvx7HxXVyt9g9
- user_id:zVt409cKNzfjXIAUvx7HxXVyt9g9
- uid:zVt409cKNzfjXIAUvx7HxXVyt9g9
- token
request.auth.token
の主なキーはOpenID ConnectのID トークンのクレームになっている。それ以外はFirebase Authentication から取得した認証情報。
request.method
※CRUDの各操作を表すので、GETであれば以下のようにget
になる。
request.path
request.path
は以下のように、CRUD操作対象のドキュメントのパスになる。
request.resource
公式のDocにはrequest.resource
というものがある事を暗示する記述はある。
request.params には、request.resource に明確に関連しないものの、
ただ、具体的にどのようなキー・バリューなのかの記述はないように見えるが、エミュレーターで確認したところ、以下のようなキー・バリューになるようである。
-
__name__
:/databases/(default)/documents/users/zVt409cKNzfjXIAUvx7HxXVyt9g9 - data
- ...
- id:zVt409cKNzfjXIAUvx7HxXVyt9g9
request.resource.data
のキー・バリューの組み合わせは、実際のCreate・Updateのデータになる。例えば以下のような実装であれば、id, email, first_name, last_name, logo_uri
になる(first_name, last_nameについてはconverterでcameCase -> snake_caseに変換しているものとしている)。
await setDoc(
doc(db, 'users', 'zVt409cKNzfjXIAUvx7HxXVyt9g9').withConverter(converter),
{
id: user.value.uid,
email: user.value.email,
firstName: firstName.value,
lastName: lastName.value,
logoUri: `https://www.gravatar.com/avatar/${md5(user.value.email)}`
}
その他の__name__
はrequest.path
と全く同じ値で、idはドキュメントIDになる模様。
request.time
これも公式のDocでは触れられていないように見えるが、ルールの設定の際には利用できるキーのようである。具体的な値は以下のようにISO Stringになる模様(画面表記はSat Mar 25 2023 18:39:36 GMT+0900 (日本標準時)
だが、ホバーすると2023-03-25T09:39:36.192Z
という表示になる)。
resource 変数
公式のDocには、
サービス内の現在の値です
と書かれているが、つまり、以下のようなキー・バリューになる(以下はdeleteDoc
を行った時のresourceの中身で、resource.data
は削除されるドキュメントのデータになっている。request.resource
と同じように思えるが、request.resource
はrequestの時のキー・バリューなのでdeleteDocの際はnull
になる。)
※ルール内でresourceを利用する際の注意として、公式のDocにも記述があるが、ドキュメントの読み取りを1回行った事になるので、その分だけコストが発生する可能性があるという事が挙げられるだろう。
条件内で resource を参照すると、サービスからの値の読み取りが最大 1 回行われます。
ユースケースに合わせたセキュリティルールを設定してみる
以下では、はじめにに書いたユースケースを満たすようなセキュリティルールを、エミュレーターのルール適用状況を参考にしつつ、設定してみたいと思う。
- グループ管理機能
- アプリ内でユーザーは自由にグループを作成できる
- 同じグループに属するユーザーは他のユーザー情報を参照できる
- グループの情報を取得・更新できるのは、グループに属するメンバーのみ
アプリ内でユーザーは自由にグループを作成できる
これは単純にログインしているユーザーであれば作成可能、という仕様なので以下のようなルールで問題ないだろう。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
match /groups/{groupId} {
allow create: if request.auth != null;
}
match /{document=**} {
allow read, write: if false;
}
}
}
グループの情報を取得・更新できるのは、グループに属するメンバーのみ
続いてグループに所属するメンバーのみがグループの情報を取得・更新できる、というルールを設定してみたいと思う。
まず、グループドキュメントでそのグループのメンバーをどう表現するか?だが、大きく分けて正規化・非正規化の2パターンある。今回は非正規化でgroupsにmember_usersというサブコレクションを持つ構造を採用する事にした。具体的には、以下のようなデータ構造になる。
- groups/{groupId}
- name:グループの名前
- ...(他のフィールド)
- member_users/{userId}(サブコレクション)
- first_name
- last_name
- ...(他のフィールド)
このデータ構造であれば、上記の要件は以下のようなルールを設定する事で実現できる(read, updateの部分)。
match /groups/{groupId} {
allow create: if request.auth != null && request.resource.data.created_by == request.auth.uid;
allow read, update: if request.auth != null && exists(/databases/$(database)/documents/groups/$(groupId)/member_users/$(request.auth.uid));
match /member_users/{userId} {
...
}
}
実際にルールの適用をエミュレーターで確認してみると、以下の通り問題ない事が確認できた(一度目のCREATEのリクエストの判定が△でERROR扱いになっているが、2度目のCREATEは〇でALLOWになっている理由については良く分かっていない…。何かわかる方がいればご教示頂けると幸いです)。
また、http://localhost:8083/emulator/v1/projects/{projectId}:ruleCoverage.html
というURLで、以下のようにルールの実装がそれぞれどのような値になっているか?を確認する事もできる。
そして、このデータ構造であれば、
- 同じグループに属するユーザーは他のユーザー情報を参照できる
という要件も簡単に実現できる。グループ情報をreadすれば、そのサブコレクションにmember_usersがあるので、グループメンバーの情報を改めて取得する必要もない。match /member_users/{userId} {...}
のルールは、以下のようになるだろう。
match /groups/{groupId} {
...
match /member_users/{userId} {
allow read: if request.auth != null && exists(/$(request.path)/$(request.auth.uid)); // サブコレクションなのでここでルール設定が必要
allow create: if request.auth != null && getAfter(/databases/$(database)/documents/groups/$(groupId)).data.created_by == request.auth.uid;
allow update, delete: if false; // functionsからのみ操作可能
}
}
// https://firebase.google.com/docs/firestore/security/rules-query?hl=ja#secure_collection_group_queries_based_on_a_field
match /{path=**}/member_users/{userId} {
allow read: if request.auth != null && request.auth.uid == resource.data.id;
}
readについては、groupをreadできるというルールだけでは不十分で、サブコレクションに対するルールが必要になる。今回は、リクエストされたmember_usersのパス(request.pathの値は例えば/databases/(default)/documents/groups/JLXoB5ZedsOOqQiUmP1U/member_users
になる)と、request.auth.uid
を結合したパスのドキュメントが存在するか?を確認=グループのメンバーにrequest.auth.uidのユーザーが属しているか?をチェックしている。これにより、同じグループのメンバーであればグループのmember_usersの全てのサブコレクションを取得(list, read)できる。
const snapShots = await getDocs(
collection(db, 'groups', '{groupId}', 'member_users')
);
ただ、writeの内、オーナーでないユーザーのmember_usersへの追加(create)については、ユーザーからの操作に対してルール作成ができない(グループのメンバーでないユーザーをメンバーにする条件を設定できない)ので、Cloud Functionsで
- (ここでは取り上げていないが)group_invitesのグループへの招待を承諾(ドキュメントの更新)されたら
をトリガーにして、groups/{groupId}/member_users/{userId}
のサブコレクションを作成するという処理を実装する必要があるだろう。updateについては、request.auth.uid
とuserIdの一致で更新を許可するルールを実装しても問題はないだろう(今回は、usersドキュメントが更新されたら、Cloud Functionsで非同期にユーザー情報が更新されたmember_usersのユーザー情報を更新する想定)。
member_usersのcreateについては、グループ作成(create)時のルールで、created_byがrequest.auth.uid
と同じであることを確認しているので、トランザクションがコミットされた後の状態を取得できるgetAfter()でグループのデータを参照し、そのdataのcreated_byがrequest.auth.uid
と同じである場合にのみ、許可するというルールにしている。
match /{path=**}/member_users/{userId} {...}
のルールについては、サブコレクションを取得するcollectionGroupのクエリのためのルールで、詳細はここに書かれている。ルールの中身としては、サブコレクションmember_usersのデータ(resource.data)のidが、request.auth.uid
と同じであるか=グループメンバーの内自分自身であるデータかを確認し、trueであればそれを取得できる、というもの。これにより以下の実装ができるので、自身が所属するグループ一覧を取得できる。
const groupPaths = [];
const members = query(
collectionGroup(db, 'member_users'),
where('id', '==', '{userId}')
);
const querySnapshot = await getDocs(members);
querySnapshot.forEach((s) => {
groupPaths.push(s.ref.parent.parent.path);
});
await Promise.all(
groupPaths.map(async (groupPath) => {
const groupRef = doc(db, groupPath).withConverter(converter);
const groupSnap = await getDoc(groupRef);
console.log(groupSnap.data());
})
);
collectionGroupのルールについての補足
collectionGroupのルールについては、分かりにくいので具体的なパターンとそのクエリ結果を列挙する。前提としてコレクションのデータ(ドキュメント)のフィールドは以下。
パターン①:クエリのフィールドkeyがルールで未定義なkeyの場合
エラーになる。このことから、ルールのresource.data
はクエリの条件に指定されているkeyでなければならない、という事が分かる。
match /{path=**}/member_users/{userId} {
allow read: if isSignedIn() && request.auth.uid == resource.data.id;
}
const members = query(
collectionGroup(db, 'member_users'),
where('dumy', '==', auth.currentUser.uid)
);
const querySnapshots = await getDocs(members);
console.log(querySnapshots.docs);
パターン②:クエリのフィールドkeyはルールに定義されているが、アクセスしようとしているデータのフィールドに該当キーがない
エラーにはならないが、データが1件も取得できない(アクセスしようとしているドキュメントのフィールドにdumy
というフィールドがないので、ルールに合致するデータなしで空になる)
match /{path=**}/member_users/{userId} {
allow read: if isSignedIn() && request.auth.uid == resource.data.dumy;
}
const members = query(
collectionGroup(db, 'member_users'),
where('dumy', '==', auth.currentUser.uid)
);
const querySnapshots = await getDocs(members);
console.log(querySnapshots.docs);
※ちなみに、以下のパターン③の事から、以下のようにドキュメントのフィールドにdumy
を追加すると、ルールに合致するのでデータが取得できる。
パターン③:クエリのフィールドkeyはルールに定義されており、かつアクセスしようとしてるデータのフィールドにもそのkeyがある
データ取得できる(クエリの条件のkeyid
がアクセスしようとしてしているドキュメントのフィールドにもあり、そのドキュメントのid
の値がクエリの条件に合致し、かつ、request.auth.uid
にも合致するのでデータが取得できる)。
match /{path=**}/member_users/{userId} {
allow read: if isSignedIn() && request.auth.uid == resource.data.id;
}
const members = query(
collectionGroup(db, 'member_users'),
where('id', '==', auth.currentUser.uid)
);
const querySnapshots = await getDocs(members);
console.log(querySnapshots.docs);
まとめとして
今回はエミュレーターを利用して、ルールの検証を行い、要件を実現するルールの実装をやってみた。エミュレーターを利用する事で、色々なデータ構造とそのデータ構造でのルール実装の可否を検証できるので、便利だなと思った。
以下のおまけに、エミュレーターを利用しながらこういう設計もできそうか?を検証した時のログを残している。良ければこちらもご参照ください。
おまけ
余談として 1ユーザーあたりのグループ作成上限を5グループまでに制限したい場合にはどうするか?
この場合、少し工夫が必要になると思われる。考え方はいくつかあると思うが、少なくとも以下のような設計の選択肢があるだろう。
まず、あるユーザーが作成済みのグループの数を判定するためのデータを同期・非同期のいずれで作成するか?で大きく設計が分かれるが、非同期の場合、例えばCloud Functionsなどでデータを作る事になると思うが、Functionsの実行タイミングによっては、ユーザーがグループを連続で作成できてしまい、制限を超えてしまう事が考えられるのでNG。そのため、同期的にデータを作る必要があると思われる。
同期的に処理するとしても、グループの数を判定できるようなデータをどう作るか?でまた設計はいくつかパターンが考えられるだろう。今回は以下のようなパターンを考えてみたが、①がシンプルでデメリットも小さいと判断して①を選択した。
- ① usersドキュメントに
owner_group_count
などのフィールドを定義し、そこに既に作成済みのグループ数を格納
グループのドキュメントのcreateのルールに、usersドキュメントのowner_group_count
を参照しその数字が5より小さい(4以下である)事をチェックするルールを追加する- この方法を取る場合、トランザクションの実装が必要になる
- groupのcreateのルールには、userのドキュメントをget()する設定になり、ドキュメント取得が1回行われる
- 将来的にユーザーの情報を同じグループのメンバーであれば参照できるようにするが、その際にそのユーザーが作成したグループの数が見えてしまう(見えてもグループのオーナーの数なので実害はない)
- ② NG:
group_owner_users
という別のドキュメントを、ドキュメントIDがuserIdになるようにして作成し、そのフィールドにgroup_idsを定義しオーナーであるグループのIDの配列を格納する- トランザクションの実装が必要
- トランザクションの実装時には、まずgroup_owner_usersをgetする事になるが、ドキュメントが存在しなければgetできないので、usersのドキュメントを作成した後に、group_owner_usersのドキュメント作成する必要がある。「group_owner_usersのドキュメント作成」をトランザクションにできないので、この方法は不可能(だと思っている)(モバイル SDK とウェブ SDK のデータ競合に書かれている通り、オプティミスティック同時実行制御なので、データを取得できないとトランザクションを張れないという理解)
- ③
user_roles
という別のドキュメントに、groupというフィールドを持たせて、そこにcreatable, uncreatableを格納する- トランザクションが必要
- 実現性はあるが、以下のような順番で処理をしなければならず、ドキュメントへのアクセス回数が増えてしまう…
- トランザクションを開始し、user_rolesを取得
- グループを作成しようとしてルール内で、user_rolesのgroupフィールドをチェックし、trueならグループが作成される
- groupからowner_user_idのようなフィールドを使い、queryでowner_user_idがログインユーザーのuidと同じデータを全部取得
- グループの数を計算し、5より小さい(4以下)ならcreatableに更新し、そうでないならuncreatableに更新
- トランザクションをコミット
- ④ NG:
group
のドキュメントのフィールドにowner_user_idを持たせる- groupをcreateを許可するルールを記述する際に、owner_user_idが〇〇であるものを全て取得し、その数を数えるというのは不可能。get()などのメソッドがあるが、ドキュメントのパスが分かっている必要があり、
/databases/{database}/documents/groups/{groupId}
のgroupIdを*
にしてgetはできない。
- groupをcreateを許可するルールを記述する際に、owner_user_idが〇〇であるものを全て取得し、その数を数えるというのは不可能。get()などのメソッドがあるが、ドキュメントのパスが分かっている必要があり、
①の考え方で実装をしてみると、以下のようになるだろう。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
function isSignedIn() {
return request.auth != null;
}
function isValidOwnerGroupCountUpdate() {
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(["owner_group_count"]) && resource.data.owner_group_count + 1 == getAfter(/databases/$(database)/documents/users/$(userId)).data.owner_group_count;
}
function isValidExcludeOwnerGroupCountUpdate() {
return !("owner_group_count" in request.resource.data.diff(resource.data).affectedKeys());
}
allow read, create: if isSignedIn() && request.auth.uid == userId;
allow update: if isValidExcludeOwnerGroupCountUpdate() || isValidOwnerGroupCountUpdate();
}
match /groups/{groupId} {
allow create: if request.auth != null && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.owner_group_count < 5;
}
match /{document=**} {
allow read, write: if false;
}
}
}
import { doc, getDoc, collection, updateDoc, runTransaction } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';
import snakecaseKeys from 'snakecase-keys';
const createGroup = async () => {
const { valid } = // 省略
if (valid) {
const auth = getAuth();
const userDocRef = doc(db, 'users', auth.currentUser.uid);
try {
await runTransaction(db, async (transaction) => {
const userDoc = await transaction.get(
userDocRef.withConverter(converter)
);
if (!userDoc.exists()) throw new Error('Document does not exist');
transaction.set(
doc(collection(db, 'groups')).withConverter(converter),
{
name: groupName.value,
createdBy: auth.currentUser.uid
}
);
await transaction.update(
userDocRef,
snakecaseKeys({
ownerGroupCount: userDoc.data().ownerGroupCount + 1
})
);
});
console.log('Transaction successfully committed!');
} catch (e) {
console.log('Transaction failed: ', e);
}
}
};
上記について少し補足をする。
-
isValidOwnerGroupCountUpdate()関数
このルール関数は、usersドキュメント更新の内、owner_group_countのフィールドのみかつ、現在の値に+1した値である場合にのみupdateを許可するための関数。これにより、トランザクション以外のリクエストでowner_group_countのみを更新するようなリクエストを受け付けないようにできる(owner_group_countのみを更新するトランザクションを明示的に実装すればこのルールをクリアできるが、(App checksで外部からのリクエストを遮断する前提で)アプリ開発者が自分で誤って実装しない限り問題はないと思われる)。 -
isValidExcludeOwnerGroupCountUpdate()関数
このルール関数は、usersドキュメントの更新に、owner_group_countのフィールドの更新がない場合には、その更新を許可するための関数。
※ルールのisValidExcludeOwnerGroupCountUpdate
関数だが、これはusersドキュメントの更新にはfirst_nameやlogo_uriも想定されるため、owner_group_countを更新しないのであればupdateを許可するために記述している。実際に動きを確認してみると、以下のようにrequest.resource.dataにowner_group_countがある場合、エラーになり更新ができない事が確認できる(L18:24というのは、ルールのisValidExcludeOwnerGroupCountUpdate()
の部分)。
const updateUser = async () => {
const auth = getAuth();
await updateDoc(
doc(db, 'users', auth.currentUser.uid),
snakecaseKeys({
firstName: '変更しました222',
ownerGroupCount: 2
})
);
};
※Firestoreのクライアント側のSDKのトランザクションはオプティミスティック同時実行制御
なので、データ更新前に更新先が取得時と同じ値か?を確認する事で実現される。
※Firestore transaction update does not use FirestoreDataConverterやHow does one use the FirestoreDataConverter with update?にある通り、どうやらtransaction.update()やupdateDoc()の際には、converterは適用されないようなので、自前でconverterの実装をする必要がある。
- 参考:トランザクションの直列化可能性と分離
- 参考:データの同時更新を防ぐための排他制御
- 参考:firebase firestore adding new document inside a transaction - transaction.add is not a function
- 参考:rules.Map.diff
- 参考:rules.MapDiff
- 参考:更新がトランザクションで行われることを保証するセキュリティルール
「グループの情報を取得・更新できるのは、グループに属するメンバーのみ」の実現方法として検討したデータ構造
グループの情報を取得・更新できるのは、グループに属するメンバーのみでは、サブコレクションでグループのメンバーを表現して要件を実現する事を考えた。
以下ではそれ以外の別の方法はないのか?を検討してみた際のログを備忘録的に残している。データを正規化して持つか、非正規化して持つか、で大きく分かれるので、その観点で分けて検討してみた。
①【非正規化】groupsのフィールドmember_usersにてMapでユーザー一覧を持つ
データの構造としては以下のようなイメージ。
- groups/{gropId}
- name(グループの名前)
- ...(他のフィールド)
- member_users(Map)
- {userId}(Mapで別ドキュメントのusersのコピーを持つイメージ)
- id:uid
- name: hogehoge
- logo_uri: https://example.com/...
- {userId}(Mapで別ドキュメントのusersのコピーを持つイメージ)
上記のようなデータ構造を取る場合、以下の要件は満たせる。
- グループの情報を取得できるのは、グループのメンバー(セキュリティルールは以下のような形で実装できる)
match /groups/{groupId} {
allow read, update: if request.auth != null && "uid" in resource.data.member_users[request.auth.uid];
}
ただ、非正規化しているので、元のusersドキュメントが更新された場合に、それをgroupドキュメントのmember_usersのフィールドに同期しなければならないが、その処理が現実的ではない。具体的に同期の処理を検討してみると、以下のように全グループドキュメントを取得する処理にならざる負えない。
- 全てのグループを取得し、そのグループのmember_usersにuserIdをキーで持つMapがあるか?を探す
- Mapがある(docRefを作成できる)場合には、そのドキュメントを更新する
queryがあるじゃないかと、思うかもしれないがqueryではmember_usersがMapなので、query(collection(db, 'groups'), where('member_users', '==', '...'))
が使えない。また、inやarray-containなど配列であれば使える条件句があるがMapだと使えない。
②【正規化】group_membersという別のドキュメントを作成し、グループのメンバーを表現する
データの構造としては以下のようなイメージ。
-
users/{userId}
- uid
- first_name
- ...(他のフィールド)
-
groups/{groupId}
- name
- ...(他のフィールド)
-
group_members/{groupMemberId}
- group_id(グループのID)
- user_id(ユーザーのID)
上記のようなデータ構造を取る場合、以下の要件を満たそうとしても一工夫が必要になる。
- グループの情報を取得できるのは、グループのメンバー
まず、groupsのドキュメントにアクセスするリクエストが来た時のルールを実装するのは、あくまでgroups/{groupId}
の部分。そのため、そこからgroup_membersのドキュメントを参照しようにも、group_members/{groupMemberId}
のgroupMemberId
が分からない状態では、get()やexists()は利用できない。
それを何とか解決する場合、group_members/{groupMemberId}
のgroupMemberId
を自動採番ではなく、{groupId}_{userId}
のように規則を持たせて採番すれば一応対応はできる。この採番ルールであれば、セキュリティルールを以下のように実装でき、「グループの情報を取得できるのは、グループのメンバー」を実現できるだろう(実際に動かして確認したわけではないのでそもそも変数を作る部分でエラーになるかもしれない。その場合、この方法は取れないのでデータ構造が根本的にNGという事になる)。
match /groups/{groupId} {
function isGroupMember(groupId) {
let id = groupId + "_" + request.auth.uid;
return exists(/databases/$(database)/documents/group_members/$(id));
}
allow read, update: if request.auth != null && isGroupMember(groupId);
上記がうまく動くのであれば、ユーザーの情報を同期する、という処理は不要なのでその意味では①のような問題は発生しない。
ただ、このデータ構造だと以下の要件を満たせない事に気づく。
- 同じグループに属するユーザーは他のユーザー情報を参照できる
この要件を実現しようとした場合、ルールを実装すべきドキュメントは参照されるusersドキュメントになる。そのリクエストにはどのgroupsのドキュメントのメンバーとしてusersドキュメントを参照している、という情報は持たせられないので、group_membersのドキュメントIDを特定できず、get()やexists()が利用できず、usersドキュメントの参照時のルールを実装できない…。
無理くりやるなら、さらに別のuser_rolesドキュメントを作成し、そのフィールドにaccessible_user_ids
を定義する、という方法もあるかもしれない。
- user_roles/{userId}
- accessible_user_ids:Array
- 0:userId aaa
- 1:userId bbb
- ...
- accessible_user_ids:Array
確かにの方法なら、usersドキュメントのルールを以下のように定義する事でグループのメンバーであればそのユーザーの情報を参照できるというルールは実装できそう。
match /users/{userId} {
allow read: if request.auth != null && (request.auth.uid == userId || userId in get(/databases/$(database)/documents/user_roles/$(request.auth.uid)).data.accessible_user_ids);
}
ただ、user_rolesドキュメントのaccessible_user_idsフィールドには、お互いのユーザーがお互いのuserIdを配列に持つ必要があるので、グループのメンバーが10人いて、11人目が参加する際には、既存の10人のaccessible_user_idsに11人目のuserIdを追加し、11人目のユーザーのaccessible_user_idsには、既存の10人のuserIdを全て追加する、という処理が必要になり、現実的な設計ではないだろう。というわけで、ここまでやるコストが見合わないと思われるので、最終的にこのデータ構造を選択する事はないと考えた。
※ちなみに、group_membersドキュメントや、user_rolesドキュメントは、ユーザー(フロントエンド)からの操作ではなく、Admin SDK(バックエンド)からの操作が必須になり、その意味でも実現したい要件に対するコストがかかりすぎるような気もする。
③【正規化】groupsのフィールドにmember_user_ids(userIdの配列)を定義し、グループのメンバーを表現する
データの構造としては以下のようなイメージ。
-
users/{userId}
- uid
- first_name
- ...(他のフィールド)
-
groups/{groupId}
- name
- member_user_ids:Array(userIdの配列)
- ...(他のフィールド)
上記のようなデータ構造を取る場合、以下の要件は満たせる。
- グループの情報を取得できるのは、グループのメンバー(セキュリティルールは以下のような形で実装できる)
match /groups/{groupId} {
allow read: if request.auth != null && request.auth.uid in resource.data.member_user_ids;
}
正規化しているので、ユーザーの情報の更新による影響はない。ただ、上記の②【正規化】group_membersという別のドキュメントを作成し、グループのメンバーを表現すると同様、このデータ構造だと以下の要件を満たせない事に気づく。
- 同じグループに属するユーザーは他のユーザー情報を参照できる
理由も②【正規化】group_membersという別のドキュメントを作成し、グループのメンバーを表現すると全く同じで、ルールを定義するusersのドキュメントにアクセスするリクエストにはgroupIdは含まれないので、get()やexists()といったメソッドが利用できない。
無理やりやるのであれば、user_rolesドキュメントを作成し…という②【正規化】group_membersという別のドキュメントを作成し、グループのメンバーを表現するで取り上げた方法になると思われる。が、それは現実的な実装にならないのでこの方法も却下。