Firestore の階層設計でセキュリティルールを劇的にシンプルにする方法

LINE ミニアプリ開発記 第 4 回

#Firestore#Firebase#セキュリティ#アーキテクチャ

Firestore のセキュリティルールは、書き始めると コレクションごとに同じような分岐を何度も書くことになって地味に長くなる やつです。ユーザーが自分のデータだけにアクセスできる、という当たり前の保証を、コレクション数 × 操作(read/write/create/update/delete)の数だけ繰り返す。これが膨らむと、ある日「あれ、ここの create に owner チェックが抜けてる?」が事故の発火点になります。

本記事は、Firestore のドキュメント階層を users/{uid}/... の入れ子構造 にすることで、セキュリティルールを 1 関数 1 行 に圧縮できる、という設計の話です。フラット設計と比較して、何が短くなって何を諦めるかを整理します。

状況・前提

設計の比較

フラット設計(よくあるやつ)

ルートにすべてのコレクションを並べて、各ドキュメントに userId フィールドを持たせる方式です。

users/{userId}
profiles/{profileId}        // userId フィールドあり
records/{recordId}          // userId フィールドあり
logs/{logId}                // userId フィールドあり

セキュリティルールは各コレクションで userId == request.auth.uid を確認します。

match /databases/{database}/documents {
  match /profiles/{id} {
    allow read, write: if request.auth != null
      && request.auth.uid == resource.data.userId;
    allow create: if request.auth != null
      && request.auth.uid == request.resource.data.userId;
  }
  match /records/{id} {
    allow read, write: if request.auth != null
      && request.auth.uid == resource.data.userId;
    allow create: if request.auth != null
      && request.auth.uid == request.resource.data.userId;
  }
  match /logs/{id} {
    allow read, write: if request.auth != null
      && request.auth.uid == resource.data.userId;
    // ...
  }
}

コレクションが増えるたびにブロックがコピペされます。create と他の操作で resource.datarequest.resource.data を使い分ける必要もあるため、ルールの書き間違い・確認漏れの温床 になります。

階層設計(今回の推奨)

ユーザー配下に子・孫リソースを入れ子にします。

users/{uid}
  profile/                       // ユーザー直下のサブコレクション
  children/{cid}/
    records/{rid}
    logs/{logId}

セキュリティルールは入口を 1 箇所だけ守れば、配下が全部守られます。

match /databases/{database}/documents {
  function isOwner(uid) {
    return request.auth != null && request.auth.uid == uid;
  }

  match /users/{uid}/{document=**} {
    allow read, write: if isOwner(uid);
  }
}

これだけ。{document=**} のワイルドカードで、users/{uid} 配下のあらゆる深さのドキュメントが対象になります。isOwner(uid) の 1 関数で読み書きどちらも判定できます。

比較表

観点フラット設計階層設計
ルールの長さコレクションごとに分岐、合計 50〜100 行1 関数 + 1 ブロック = 5〜10 行
新コレクション追加時の作業同パターンを 1 ブロック追記追加作業ゼロ(自動で守られる)
userId フィールドの保守各ドキュメントに必須、漏れると無防備パス自体が所有者を表すので不要
横断クエリwhere('userId', '==', uid) で 1 発collectionGroup を使う
マイグレーション容易性フィールド追加で済むパスを変えるとクライアント全書き換え

ルールを短く、追加作業をゼロに寄せられる代わりに、横断クエリで collectionGroup を使う ことになるのが主なトレードオフです。

階層設計の細部

型バリデーションも入口で 1 度だけ

セキュリティルールで型チェックを入れたい場合も、入口の match の中で 1 関数として定義すれば配下にかかります。

match /databases/{database}/documents {
  function isOwner(uid) {
    return request.auth != null && request.auth.uid == uid;
  }

  function isString(v) { return v is string; }
  function isPositiveInt(v) { return v is int && v >= 0; }

  match /users/{uid} {
    allow read, write: if isOwner(uid);

    match /children/{cid} {
      allow create: if isOwner(uid)
        && isString(request.resource.data.name)
        && isString(request.resource.data.birthDate);
      allow read, update, delete: if isOwner(uid);

      match /records/{rid} {
        allow read, write: if isOwner(uid);
      }
    }
  }
}

入口の所有チェックと、子コレクションでの作成時バリデーションを分けて書ける、という二段の使い分けが可能です。

横断クエリは collectionGroup

cron で「全ユーザーのレコードを 1 度に取りたい」場合は collectionGroup を使います。

import { getFirestore } from "firebase-admin/firestore";

const records = await getFirestore()
  .collectionGroup("records")
  .where("scheduledDate", "==", tomorrow)
  .where("completedDate", "==", null)
  .get();

collectionGroup 用のインデックスは firestore.indexes.json に明示的に書く必要があります(複合インデックス忘れ問題は別記事 #5 参照)。

サーバー側からはルールが効かない

Cloud Functions の firebase-admin から書き込むときは セキュリティルールがバイパスされる 点だけ注意します。階層設計でもフラット設計でも同じですが、サーバー側から書き込むときは users/{uid}/... のパスを アプリ側で手で組み立てる必要 があるので、ヘルパー関数にしておくのがおすすめです。

function userPath(uid: string) {
  return `users/${uid}`;
}

function recordsPath(uid: string, cid: string) {
  return `${userPath(uid)}/children/${cid}/records`;
}

階層設計が向かないケース

私のアプリのように ユーザーごとにデータが完結する 個人開発には階層設計が刺さる、という判断でした。

学び・余談

「セキュリティルールが短い」は単に行数の問題ではなく、新しいコレクションを足したときに既存のルールに影響しない ことが本当の効きです。フラット設計だと、新しいコレクションごとに「ルール書いたっけ」と確認する習慣が要りますが、階層設計だと入口を守っている安心感が、機能追加のたびに払うレビューコストを下げてくれます。

シンプルなセキュリティルールは、半年ぶりに自分のコードを開いた未来の自分への一番の贈り物だと思っています。

関連記事

参考


← ブログ一覧へ