Skip to content

Firestore least-privilege migration runbook

This runbook is for the production Firebase project used by MyRecipes:

  • Firebase/Google Cloud project ID: myrecipes-e8640
  • Firestore database: (default)
  • Local rules file: firestore.rules
  • Local index file: firestore.indexes.json
  • Cloud Functions directory: functions/

Follow the steps in order. Do not deploy firestore.rules until the backup, indexes, data migrations, compatible TestFlight build, and canary checks are complete. The new rules depend on deterministic parent document IDs and valid recipe_box_id values.

What this migration changes

The migration creates deterministic copies, validates them, and removes the superseded legacy parents before security rules are tightened:

  1. It copies legacy randomly named parent documents to deterministic document IDs for recipes, recipeboxes, and sl_user_shopping_lists.
  2. It adds recipe_box_id to both copies when the correct cookbook can be determined safely.
  3. It proves that each legacy parent and deterministic copy have identical application data, then removes the legacy parent in one atomic transaction.
  4. It deploys the composite indexes used by the private recipe catalog.
  5. It moves legacy Recipe Private notes into the owner-only private_recipe_notes collection.
  6. It replaces the currently deployed Firestore rules with the versioned rules in this repository.

The recipe-box migration refuses to apply any updates if even one recipe is ambiguous or unmatched. The cleanup refuses to delete anything if an ID is malformed, a copy is missing, or any application field differs. The duplicate state between the copy and cleanup commands is transitional and is not valid for application or TestFlight testing.

Stop conditions

Stop immediately and do not continue if any of these are true:

  • The active project is not exactly myrecipes-e8640.
  • The Firestore export does not finish successfully.
  • An index is not READY.
  • Any migration dry run reports a conflict, ambiguity, unmatched recipe, missing ID, or differing legacy/canonical data.
  • The local emulator tests fail.
  • The compatible TestFlight build cannot read, create, edit, or delete recipes.
  • A canary account can access another account's unshared recipe box.

Do not “work around” a stop condition by editing production records without first recording the affected document paths and confirming the intended owner.

1. Required access and software

The operator needs:

  • Owner, Editor, Cloud Datastore Owner, or Datastore Import Export Admin access to project myrecipes-e8640.
  • Storage Admin access to the backup bucket.
  • Billing enabled. Managed Firestore export requires billing; Firebase projects must be on the Blaze plan.
  • Flutter and Dart versions supported by the release branch.
  • Node.js 22 and npm.
  • Java, required by the local Firestore emulator.
  • Firebase CLI (firebase).
  • Google Cloud CLI (gcloud).

From the repository root, confirm the tools are available:

firebase --version
gcloud --version
node --version
npm --version
java -version

Node should report major version 22.

Create a private, ignored evidence directory from the repository root:

mkdir -p docs/release-evidence/p0-firestore-YYYYMMDD

Replace YYYYMMDD with the migration date. The repository ignores docs/release-evidence/; do not remove that ignore rule or commit production output without a separate security review.

2. Start from the reviewed release commit

Do not perform a production migration from an unreviewed or dirty branch.

git status --short
git branch --show-current
git log -1 --oneline

Expected conditions:

  • git status --short prints nothing.
  • The checked-out commit includes the reviewed Firestore migration PR.
  • firebase.json points to firestore.rules and firestore.indexes.json.

Review the configured files:

sed -n '1,240p' firebase.json
git diff HEAD -- firestore.rules firestore.indexes.json

The second command must print nothing.

3. Authenticate and verify the production project

Authenticate the Firebase CLI:

firebase login
firebase login:list
firebase use myrecipes-e8640
firebase use

The final command must print only:

myrecipes-e8640

Authenticate the Google Cloud CLI and Application Default Credentials. The migration scripts use the Firebase Admin SDK, which reads Application Default Credentials when run locally.

gcloud auth login
gcloud auth application-default login
gcloud config set project myrecipes-e8640
gcloud auth application-default set-quota-project myrecipes-e8640
gcloud config get-value project

The last command must print myrecipes-e8640. Also confirm the project name before continuing:

gcloud projects describe myrecipes-e8640 \
  --format='table(projectId,name,projectNumber)'

If gcloud reports invalid_grant, rerun both login commands. Do not use credentials from another Google account or project.

4. Create and verify a Firestore backup

4.1 Find the Firestore location

gcloud firestore databases describe \
  --project=myrecipes-e8640 \
  --database='(default)' \
  --format='value(locationId)'

Write down the returned location. The backup bucket should be created in the same or a nearby location.

4.2 Create a dedicated backup bucket if one does not exist

Bucket names are globally unique. Choose a name that is not already taken. The examples below use gs://myrecipes-e8640-firestore-backups; replace it if Google Cloud reports that the name is unavailable.

Check whether the bucket already exists:

gcloud storage buckets describe \
  gs://myrecipes-e8640-firestore-backups \
  --project=myrecipes-e8640

If it does not exist, create it. Replace FIRESTORE_LOCATION with the exact location returned in step 4.1:

gcloud storage buckets create \
  gs://myrecipes-e8640-firestore-backups \
  --project=myrecipes-e8640 \
  --location=FIRESTORE_LOCATION \
  --uniform-bucket-level-access

Do not use a Requester Pays or Rapid bucket for Firestore export/import.

4.3 Export the complete database

Choose a unique, human-readable export folder. Replace the example timestamp instead of reusing it:

gcloud firestore export \
  gs://myrecipes-e8640-firestore-backups/pre-rules-20260720-0900 \
  --project=myrecipes-e8640 \
  --database='(default)'

Wait for the command to report success. Closing the terminal does not cancel a managed export, but do not continue until its operation completes.

If the terminal disconnects, list recent operations:

gcloud firestore operations list \
  --project=myrecipes-e8640

Then inspect the relevant operation using the operation name returned above:

gcloud firestore operations describe OPERATION_NAME \
  --project=myrecipes-e8640

The operation must report completion without an error. Verify that files exist:

gcloud storage ls --recursive \
  gs://myrecipes-e8640-firestore-backups/pre-rules-20260720-0900

Record the exact export path in the release notes. Firestore export incurs one read per exported document and Cloud Storage charges for the stored files.

Console alternative

If the CLI cannot be used:

  1. Open Google Cloud Console for project myrecipes-e8640.
  2. Open Firestore Database.
  3. Select Import/Export.
  4. Select Export and then Export entire database.
  5. Select the dedicated backup bucket.
  6. Start the export and wait until its status is completed.

Do not treat a started or running export as a completed backup.

5. Preserve the currently deployed rules

Firestore rules do not have a one-click production rollback in the Firebase CLI. Before deployment:

  1. Open Firebase Console for myrecipes-e8640.
  2. Open Firestore Database > Rules.
  3. Copy the complete currently published rules text.
  4. Save it in the release evidence folder as firestore.rules.before-p0-YYYYMMDD.
  5. Record the current rules release timestamp shown in the console.

This saved file is only for emergency rule rollback. The repository remains the source of truth for the new rules.

6. Verify all required indexes are ready

List deployed indexes:

firebase firestore:indexes \
  --project myrecipes-e8640 \
  --pretty

Every listed index must show READY. In particular, confirm this index:

(recipes) -- (recipe_box_id,ASCENDING) (id,ASCENDING)

Compare the deployed definitions with firestore.indexes.json. If a required index is absent, deploy indexes only:

firebase deploy \
  --only firestore:indexes \
  --project myrecipes-e8640

This command does not deploy Firestore rules. Wait for every new index to become READY before running the application query or deploying rules. Re-run the index listing command until it is ready.

If the Firebase console remains on Building but the CLI shows READY, refresh or reopen the console. The CLI result is the authoritative deployed state for this runbook.

For a genuinely long-running index, inspect progress:

gcloud firestore operations list \
  --project=myrecipes-e8640

gcloud firestore operations describe OPERATION_NAME \
  --project=myrecipes-e8640

Check state, workEstimated, and workCompleted. Do not delete and recreate an index that is making progress. If it enters NEEDS_REPAIR or reports an index-entry limit, save the complete error before retrying.

7. Install dependencies and run local validation

From the repository root:

cd functions
npm ci
npm run test:migrations
npm test
npm run test:rules
cd ..

Expected results:

  • Migration resolver tests pass.
  • Purchase verifier tests pass.
  • Firestore emulator rules tests pass.

The emulator is local and does not modify production Firestore. If it cannot start, check whether ports 4400, 4500, 8080, or 9150 are already occupied, stop the conflicting local process, and rerun the test. Do not change production configuration to solve a local port conflict.

Also validate the Flutter query changes:

flutter analyze --no-pub

Do not continue with new analyzer errors.

8. Run the pre-copy production migrations in dry-run mode

The following commands read production data but do not write it because --apply is intentionally omitted.

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:parent-ids
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:recipe-box-ids
cd ..

Save the complete output in the release evidence.

Do not run the cleanup yet. Before deterministic copies exist it should have no verified pairs to remove. Its meaningful dry run occurs after both additive apply commands in step 10.

Parent-ID dry-run output

The parent migration checks recipes, recipeboxes, and sl_user_shopping_lists. It reports how many legacy parents need deterministic copies. It stops on missing IDs or conflicting deterministic targets.

Recipe-box-ID dry-run output

The recipe migration reports:

  • Recipes whose recipe_box_id is already valid.
  • Recipes that can be backfilled safely.
  • Recipes requiring manual resolution.

It matches cookbook names case-insensitively and uses cookbook ownership to disambiguate duplicate names. It does not guess if multiple candidates remain.

9. Resolve migration errors safely

Do not run either --apply command while errors remain.

For every ambiguous or unmatched recipe path printed by the dry run:

  1. Open that exact recipe document in Firebase Console.
  2. Record its id, recipe_name, recipe_box, and creator_uid.
  3. Find the intended document in the recipeboxes collection.
  4. Confirm the cookbook's owner_uid, recipe_box, and numeric id.
  5. Confirm the association with the cookbook owner or product owner.
  6. Add or correct the recipe's numeric recipe_box_id only after confirmation.
  7. Rerun both dry runs.

Do not resolve a duplicate cookbook name based only on its display name. Names are mutable and are not guaranteed to be unique.

Continue only when both dry runs complete without conflicts or unresolved recipes.

10. Apply, verify, and clean up the migration

Reconfirm the project immediately before applying:

firebase use
gcloud config get-value project

Both commands must return myrecipes-e8640.

Run the deterministic parent copy first:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 \
  npm run migrate:parent-ids -- --apply
cd ..

Review the output before continuing. Then backfill recipe-box IDs:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 \
  npm run migrate:recipe-box-ids -- --apply
cd ..

At this point both legacy and deterministic parent documents exist. Do not open the app or begin TestFlight validation in this transitional state.

Rerun the two additive dry runs, then run the cleanup dry run:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:parent-ids
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:recipe-box-ids
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:cleanup-legacy-parents
cd ..

The parent migration should report zero remaining legacy parents to copy. The recipe migration should report zero recipes needing backfill and zero recipes requiring manual resolution. The cleanup must report the expected verified legacy-parent counts and zero conflicts for every collection.

The cleanup is a production deletion. Confirm that the managed export is still present and completed, save the dry-run output, and reconfirm the project. Then run:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 \
  npm run migrate:cleanup-legacy-parents -- --apply
cd ..

The cleanup re-reads and revalidates every source/copy pair inside one Firestore transaction. If any record changed after the dry run, Firestore retries the transaction and validation runs again. One conflict blocks the entire transaction. The command must report the expected total deleted atomically.

Finally, rerun all three commands without --apply. Expected results:

  • Parent migration: zero legacy parents to copy.
  • Recipe-box migration: all canonical recipes already valid, zero backfills, and zero manual resolutions.
  • Cleanup: zero parents to delete, the expected parents already cleaned, and zero conflicts.

These commands are production writes. Do not run them concurrently and do not interrupt them. They are idempotent, so rerunning after a clear transport failure is safe, but first inspect whether the prior process completed.

10A. Move legacy private notes before deploying note privacy rules

The reviewed rules treat documents in private_recipe_notes as owner-only. Older versions represented privacy using note_type == "Recipe Private" in the public recipe_notes collection. Those documents must be moved before deploying the new rules.

From the repository root, confirm the project and run the dry run:

firebase use
gcloud config get-value project
cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:private-notes
cd ..

Both project commands must report myrecipes-e8640. The migration must say DRY RUN and report the number of legacy private notes that can be moved. Save the output in the release-evidence folder. If the command reports an error, stop; do not deploy the rules or manually copy individual documents.

Confirm the managed Firestore export from section 4 is still present. Apply the migration:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 \
  npm run migrate:private-notes -- --apply
cd ..

The apply run creates each owner-only document with the same Firestore document ID, changes its type to Recipe Chef, sets is_private to true, preserves existing timestamps when available, and deletes the old public document in the same batch.

Rerun the dry run:

cd functions
GOOGLE_CLOUD_PROJECT=myrecipes-e8640 npm run migrate:private-notes
cd ..

It must report 0 legacy private notes can be moved. In Firebase Console, confirm the reported number of documents now exists in private_recipe_notes, and confirm there are no documents with note_type == "Recipe Private" in recipe_notes. Do not deploy the new rules until both checks pass.

11. Ship and validate the compatible application before rules

The application containing the deterministic queries and Firebase compatibility fixes must reach testers before the restrictive rules are deployed.

  1. Build the intended production/TestFlight flavor from the reviewed commit.
  2. Keep Firebase App Check enforcement disabled.
  3. Upload and install the new TestFlight build on a clean device or simulator.
  4. Sign in with an owner canary account.
  5. Confirm the complete recipe count in a large cookbook such as Soucy Family Cookbook.
  6. Create, import, edit, move, save, and delete a test recipe.
  7. Verify recipe details and cooking mode.
  8. Verify shopping-list creation, editing, checkoff, checkout, and persistence.
  9. Sign out and sign back in.
  10. Repeat read tests with an authorized member account and an unrelated account.

The unrelated account must not see the owner's private cookbook or recipes. Do not deploy rules if any expected operation fails in the compatible build.

12. Deploy Firestore rules only

Review what will be deployed:

git status --short
git diff HEAD -- firestore.rules firebase.json
firebase use

Expected results:

  • The working tree is clean.
  • The diff command prints nothing.
  • The active project is myrecipes-e8640.

Deploy only Firestore rules:

First validate the deployment without publishing it:

firebase deploy \
  --only firestore:rules \
  --dry-run \
  --project myrecipes-e8640

The dry run must report that firestore.rules compiled successfully. Then run the production deployment:

firebase deploy \
  --only firestore:rules \
  --project myrecipes-e8640 \
  -m "P0 least-privilege Firestore rules after parent migrations"

This command publishes firestore.rules and overwrites the Firestore rules currently shown in Firebase Console. It does not deploy Storage rules, Functions, Hosting, or App Check enforcement.

Save the complete deployment output and deployed rules release timestamp.

13. Perform immediate production canary checks

Test immediately after deployment:

Owner account

  • Can read owned recipe boxes and every recipe in them.
  • Can create, edit, move, and delete a test recipe.
  • Can read and modify owned shopping lists.

Authorized member account

  • Can read a recipe box explicitly shared with that account.
  • Can read its recipe details, ingredients, directions, notes, and categories.
  • Cannot edit or delete recipes owned by the cookbook owner.
  • Can use only the shopping-list collaboration behaviors intentionally shipped.

Unrelated account

  • Cannot read another user's private recipe box or recipe.
  • Cannot read recipe children by guessing IDs.
  • Cannot modify another user's recipes, shopping lists, or metadata.

Signed-out client

  • Cannot read private records or authenticated catalogs.

Monitor application crash/error reporting and Firebase permission-denied logs. If legitimate owner operations fail, start the emergency rule rollback below.

14. Emergency rule rollback

Rules cannot be automatically rolled back by the Firebase CLI. To restore the previous rules:

  1. Open Firebase Console for myrecipes-e8640.
  2. Open Firestore Database > Rules.
  3. Paste the exact rules saved in step 5.
  4. Review the project name again.
  5. Select Publish.
  6. Repeat the owner and cross-user canary checks.
  7. Record the rollback time and observed failure.

Do not use a broad allow read, write: if true rule as a rollback.

The data migrations are additive and should normally remain in place after a rule rollback. Do not delete deterministic copies or remove recipe_box_id fields during an incident. Any cleanup requires a separate reviewed script.

15. Restoring data from the managed export

Import is a disaster-recovery action, not the normal rollback for this migration. A Firestore import overwrites documents with matching IDs and does not remove unrelated documents created after the export.

Before importing:

  1. Stop application writes or place the app in maintenance mode.
  2. Confirm the exact export path and its completed status.
  3. Confirm with the project owner that overwriting current documents is intended.
  4. Record all changes made after the export, because the export is not an exact transactionally consistent snapshot of a live database.

Only then run an import using the export metadata folder:

gcloud firestore import \
  gs://myrecipes-e8640-firestore-backups/pre-rules-20260720-0900 \
  --project=myrecipes-e8640 \
  --database='(default)'

Wait for the import operation to complete and then repeat all canary checks.

16. Final release evidence

Attach or record all of the following before closing the Firebase P0 issues:

  • Reviewed commit and PR number.
  • Firestore export path and completed operation status.
  • Pre-deployment rules copy and timestamp.
  • Deployed index listing showing every index as READY.
  • Both migration dry-run outputs before and after apply.
  • Parent-copy, recipe-box-ID, and legacy-cleanup apply outputs.
  • Cleanup dry run showing identical pairs and zero conflicts before deletion.
  • Local migration, purchase, and Firestore emulator test results.
  • TestFlight build number and regression checklist.
  • Firestore rules deployment output and release timestamp.
  • Owner, authorized-member, unrelated-user, and signed-out canary results.
  • Crash/error monitoring results after deployment.

Only after this evidence is complete should issues #177, #178, #182, and #183 be evaluated for closure. Storage rules and App Check enforcement have their own rollout steps and must not be enabled merely because Firestore rules were deployed successfully.

Official references