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:
- It copies legacy randomly named parent documents to deterministic document
IDs for
recipes,recipeboxes, andsl_user_shopping_lists. - It adds
recipe_box_idto both copies when the correct cookbook can be determined safely. - It proves that each legacy parent and deterministic copy have identical application data, then removes the legacy parent in one atomic transaction.
- It deploys the composite indexes used by the private recipe catalog.
- It moves legacy
Recipe Privatenotes into the owner-onlyprivate_recipe_notescollection. - 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 --shortprints nothing.- The checked-out commit includes the reviewed Firestore migration PR.
firebase.jsonpoints tofirestore.rulesandfirestore.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:
- Open Google Cloud Console for project
myrecipes-e8640. - Open Firestore Database.
- Select Import/Export.
- Select Export and then Export entire database.
- Select the dedicated backup bucket.
- 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:
- Open Firebase Console for
myrecipes-e8640. - Open Firestore Database > Rules.
- Copy the complete currently published rules text.
- Save it in the release evidence folder as
firestore.rules.before-p0-YYYYMMDD. - 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_idis 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:
- Open that exact recipe document in Firebase Console.
- Record its
id,recipe_name,recipe_box, andcreator_uid. - Find the intended document in the
recipeboxescollection. - Confirm the cookbook's
owner_uid,recipe_box, and numericid. - Confirm the association with the cookbook owner or product owner.
- Add or correct the recipe's numeric
recipe_box_idonly after confirmation. - 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.
- Build the intended production/TestFlight flavor from the reviewed commit.
- Keep Firebase App Check enforcement disabled.
- Upload and install the new TestFlight build on a clean device or simulator.
- Sign in with an owner canary account.
- Confirm the complete recipe count in a large cookbook such as Soucy Family Cookbook.
- Create, import, edit, move, save, and delete a test recipe.
- Verify recipe details and cooking mode.
- Verify shopping-list creation, editing, checkoff, checkout, and persistence.
- Sign out and sign back in.
- 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:
- Open Firebase Console for
myrecipes-e8640. - Open Firestore Database > Rules.
- Paste the exact rules saved in step 5.
- Review the project name again.
- Select Publish.
- Repeat the owner and cross-user canary checks.
- 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:
- Stop application writes or place the app in maintenance mode.
- Confirm the exact export path and its completed status.
- Confirm with the project owner that overwriting current documents is intended.
- 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.