Article
🔐 Firebaseで権限別共有リンクを導入する:既存URLを壊さないaccessVersion設計
URLを知っている人が参加できるWebサービスに「管理・編集・閲覧」の権限を追加したい。しかし、既存利用者のURLを一律無効化すると、セキュリティ強化のための変更が利用停止につながります。
スコア管理サービス PointPia で採用したのは、データ本体に認可方式のバージョンを持たせ、古いデータと新しいデータを明示的に分ける設計です。2026年10月9日に本番リリースした実例から、実装と検証のポイントを整理します。以下のコードは設計を説明する簡略例で、実装の完全なコピーではありません。
1. 権限記録の有無だけで認可方式を判定しない
認可とは、相手に「何を操作できるか」を許可する仕組みです。本人や資格情報を確認する認証とは区別します。
単純に「権限記録がなければ旧方式で許可する」とすると、新方式で権限記録だけが削除された場合にも旧方式として扱われ、意図せず認可が緩みかねません。
// NG: 新方式の認証データ破損でも旧方式に降格してしまう
if (!accessRecord) return "legacy";
ゲーム本体に accessVersion を保存し、次のように判断します。
type Game = { accessVersion?: unknown };
type AccessMode = "legacy" | "role-based";
function resolveAccessMode(
game: Game,
accessRecordExists: boolean,
): AccessMode {
const hasVersion = Object.prototype.hasOwnProperty.call(
game,
"accessVersion",
);
if (!hasVersion && !accessRecordExists) return "legacy";
if (game.accessVersion === 2 && accessRecordExists) return "role-based";
// 矛盾、記録欠落、未知のバージョンは拒否する
throw new Error("INVALID_ACCESS_STATE");
}
Object.prototype.hasOwnProperty.call は、オブジェクトに特定のフィールドが存在するかを確認します。単なる undefined 比較より「フィールドがない」と「存在するが不正」を分けられます。
この関数が行うのは方式の選択までです。role-based の場合は、サーバー側でキーのハッシュ照合、失効、管理・編集・閲覧の許可を検証します。legacy の場合も従来のアクセス条件を適用します。画面でボタンを隠すだけでは認可の代わりになりません。
不正な状態で許可を与えず、安全側に失敗することを fail closed と呼びます。
ゲーム本体の accessVersion(認可方式)と、権限記録の gameAccess.version(記録の保存形式)は別の概念です。保存形式の変更を認可方式の切り替えと混同しないようにします。
2. 新規ゲームと権限データを原子的に作成する
新規ゲームの本体だけ保存でき、資格情報の作成に失敗すると、作成者がゲームに入れなくなります。Firestore のトランザクションは複数の書き込みを一単位にまとめ、すべて成功するか、すべて適用されないかを保証します。
// 概念例:入力の検証・キーの発行とハッシュ化は実行済み
await db.runTransaction(async (tx) => {
tx.create(gameRef, {
name: validatedName,
accessVersion: 2,
});
tx.create(accessRef, {
version: 1, // 資格情報「保存形式」のバージョン
adminHash,
editorHash,
viewerHash,
});
});
tx.create は指定先のドキュメントが存在する場合に失敗する操作です。実装例では認証情報の生成・適切な保護、アクセス権限の検査、入力検証を省略しています。クライアントが送った accessVersion を信用してはいけません。
Firestoreのトランザクション関数は競合時に再実行され得ます。その内部にメール送信などの外部副作用を入れない設計も重要です。
3. 更新前から開いたままのブラウザーを考える
新しい作成APIが管理キーを返しても、以前のフロントエンドにはキーを保存する機能がありません。作成後に管理リンクを失うことを防ぐため、PointPiaでは新仕様に対応したクライアントかを確認し、古い場合はゲームを作る前に再読み込みを案内します。
ただし「新バージョンの画面です」という自己申告は管理者権限の証明にはなりません。互換性チェックと認可を独立して設計します。
4. APIのキャッシュ設定は本番ドメインで確認する
キャッシュとは、HTTPレスポンスを保存して再利用する仕組みです。初回本番確認では、API側の no-store 設定があっても、Hosting側の公開キャッシュ設定によって実際のレスポンスが意図と異なる問題が見つかりました。
Cache-Control はHTTPキャッシュの挙動を指定するヘッダーです。
public:共有キャッシュへの保存を許可する。private:共有キャッシュへの保存を許可しない。no-cache:保存できるが、再利用前に原則再検証する。no-store:レスポンスの保存をさせない。
権限依存のAPIには、例えば次のように指定します。
Cache-Control: private, no-cache, no-store, must-revalidate
Firebase Hosting公式資料では、firebase.json の headers で経路別にキャッシュ制御を設定できると説明されています。静的なNext.jsの成果物と、権限依存APIへ同一の長期キャッシュ設定を適用しないようにします。
公開テスト用URLのヘッダーを確認する例は次のとおりです。
curl -I https://example.com/public-test-path
curl はHTTP通信するコマンド、-I はレスポンスのヘッダーだけを調べるオプションです。認証が必要なAPIではテスト用クライアントを使い、秘密キーをコマンド履歴や記事へ残さないようにします。Firebase直のURLと正規ドメインを両方確認します。
5. 新規ゲームだけでなく、既存URLを本番でテストする
| ケース | 期待動作 |
|---|---|
| 古いゲーム、バージョンなし | 既存URL・履歴を利用できる |
| 新方式、管理キー | 管理操作できる |
| 新方式、編集キー | 記録できるが管理は拒否される |
| 新方式、閲覧キー | 読み取りのみ許可される |
| 新方式で権限記録を欠損させる | 拒否し、旧方式へ戻さない |
| 未知のバージョン | 拒否する |
| 古い作成画面 | 保存前に再読み込みを求める |
| 再発行された共有リンク | 古いリンクは失効する |
PointPiaでは通常・ゼロサム・麻雀・キャッシュポーカー・大会の全5モードを本番画面で確認しました。更新前に作った専用検証ゲームを更新後も同じURLと履歴で利用できるか確認することで、一般利用者のデータを操作せず互換性をテストできました。
この検証では別の問題も発見しました。画面上の主要操作が動いても、裏側のポーカー状態取得APIが失敗していました。必要なFirestoreのインデックス(検索を効率化する索引)が本番デプロイから漏れていたためです。コードだけでなくSecurity Rules、インデックス、バックエンドの設定もリリース確認の対象にします。
トレードオフと公開状態
既存ゲームの従来URLを守る代わりに、古いゲームへ新しい三役割の制限をさかのぼって適用したわけではありません。既存URLを知る人はこれまでの方式で利用します。古いゲームの所有者を確実に識別する仕組みがない以上、強制移行には別の本人確認・再招待設計が必要です。
また、DB管理者が accessVersion と資格情報の両方を直接消去した場合、元からバージョンのなかった旧データと区別できません。通常のAPIからバージョンを変更させないこと、クライアントからのFirestore直接アクセスを制限することも、この設計の前提です。
権限別共有リンクは2026年10月9日にPointPiaで本番リリース済みです。既存ゲーム自体を新方式へ切り替える機能は、今回のリリースには含まれません。
まとめ
認可方式の変更は「新方式の安全性」と「既存利用者がそのまま使えること」の両立が必要です。データ本体の明示的なバージョン、欠損時の拒否、原子的な作成、古いブラウザー画面、本番キャッシュと既存URLの検証を一組にして考えます。
この設計は、ゲームスコア管理サービス PointPia で採用しました。