Skip to main content

Firestore security rules

The canonical source is firestore.rules in the main repo. This page is a readable map of what those rules grant and deny.

The rule set runs at version rules_version = '2'. All operations require authentication unless explicitly noted, and authentication means Google sign-in only — the isGoogleAuth() helper rejects anonymous, email/password, phone, and any other provider that may slip through the Auth flow.

Helper functions

The rules file declares a small library of helpers used across every collection.

HelperWhat it checks
isGoogleAuth()request.auth != null AND request.auth.token.firebase.sign_in_provider == 'google.com'.
isAuthenticated()Currently identical to isGoogleAuth(). The indirection lets us flip in additional providers later without rewriting every rule.
isAdmin()isAuthenticated() AND request.auth.token.email == 'aoneahsan@gmail.com'.
isOwner(uid)isAuthenticated() AND request.auth.uid == uid.
existingOwnerUid()Reads resource.data.ownerUid for owner-vs-update checks.
isValidPhone(p)p is string && size between 7 and 20.
isValidMessage(m)m is string && size between 1 and 500.
isValidFrequency(f)f in ['once', 'daily', 'send_now_batch'] (plus 'batch_send' allowed by the batch path).
isValidStatus(s)s in ['pending', 'scheduled', 'sending', 'sent', 'failed', 'cancelled'].
isAssignedDeviceOwner()Multi-actor path: for batch_send jobs, allow the assigned-device's owner to read + write whitelisted progress fields without satisfying isOwner against the admin's UID.

Collection-by-collection access table

CollectionReadCreateUpdateDelete
sms_jobs/{id}Admin, owner, assigned-device owner (batch_send)Auth user — must set their own ownerUid, valid shape, status='pending', attemptCount=0Admin/owner (full schema check) OR assigned-device owner (whitelisted fields only)Admin or owner
sms_logs/{id}Admin onlyAdmin OR auth user (logging their own job)❌ Forbidden (append-only)❌ Forbidden
sms_devices/{id}Admin or ownerAuth user — must set their own ownerUidOwner with rename-cooldown gate + status-restriction; admin bypassesAdmin only
sms_users/{userId}Admin or self(write covers create)Admin or self(write covers delete)
sms_settings/{id}Any auth userAdminAdminAdmin
sms_config/{id}Any auth userAdminAdminAdmin
sms_advertisements/{id}Public (no auth)AdminAdminAdmin
sms_contact_submissions/{id}Admin or submitter (when signed in)Anonymous create with strict shapeAdmin onlyAdmin only
sms_ad_user_state/{id}Admin or selfAuth user — must set userId == auth.uidSelfAdmin only
sms_blog_posts/{id}Auth user (published) or admin (any)AdminAdminAdmin
sms_user_files/{userId}Admin or self(write covers create)Admin or self(write covers delete)
sms_contacts/{id}Admin or ownerAuth user — must set their own ownerUidAdmin or owner, ownerUid immutableAdmin or owner
sms_quick_replies/{id}Admin or ownerAuth user — own ownerUidAdmin or owner, ownerUid immutableAdmin or owner
sms_drafts/{id}Admin or ownerAuth user — own ownerUidAdmin or owner, ownerUid immutableAdmin or owner
sms_templates/{id}Admin or ownerAuth user — own ownerUidAdmin or owner, ownerUid immutableAdmin or owner
sms_contact_groups/{id}Admin or ownerAuth user — own ownerUidAdmin or owner, ownerUid immutableAdmin or owner
sms_user_achievements/{id}Admin or selfAuth user — own userIdSelfAdmin only
sms_user_usage/{id}Admin or selfAuth user — own userIdSelfAdmin only
sms_guest_quota/{guestId}Public readAnonymous create with shape (count ≤ 10, monotonic)Anonymous count-bump OR auth user link-stampAdmin only
sms_batches/{id}Admin or any device-owner whose device is in assignedDeviceIdsAdmin — fan-out ≤10, every assigned device healthyAdmin OR assigned-device owner (counter fields + deviceProgress only)Admin only
sms_batches/{id}/recipients/{rid}Admin or any device-owner via parent-batch checkAdmin onlyAdmin OR auth user (whitelisted field set, attempt-budget invariants)Admin only
Catch-all /{document=**}

Multi-actor rules: how the runner writes without admin

Two paths in the rules let a non-admin volunteer write to documents created by the admin. They power Phase 7's batch-send runner.

1. Batch-send job updates (sms_jobs)

The standard owner path requires request.auth.uid == resource.data.ownerUid. For batch_send jobs, however, the runner runs as the volunteer device's user — not the admin. The isAssignedDeviceOwner() helper makes that path safe:

isAssignedDeviceOwner() =
request.auth != null
&& resource.data.frequency == 'batch_send'
&& resource.data.assignedDeviceIds.size() == 1
&& get(/sms_devices/{assignedDeviceIds[0]}).data.ownerUid == request.auth.uid

When this returns true, the runner may update only the whitelisted field set: status, attemptCount, lastError, lastAttemptAt, processingDeviceId, processingStartedAt, progressCompleted, progressSent, progressFailed, sentByDeviceId, updatedAt. It cannot change ownerUid, frequency, assignedDeviceIds, or the recipient list — those stay immutable on this path. The isValidStatus() check still applies.

2. Batch progress counters (sms_batches)

Admins create batches and seed counters. Runners then increment sentCount, failedCount, and deviceProgress atomically via Firestore's DocumentTransform.increment. The rules path isBatchAssignedToCaller() walks the (hand-unrolled, max-10) assignedDeviceIds array to confirm the caller owns at least one device on the list. When that holds, the runner may write only the four counter fields plus updatedAt and deviceProgress. The monotonic invariant is enforced: request.resource.data.sentCount >= resource.data.sentCount (counters can never decrease).

3. Recipient state machine (sms_batches/{id}/recipients)

The runner writes one document per recipient per dispatch attempt. The rules permit the whitelisted update — status, lastError, updatedAt, sentAt, sentByDeviceId, plus the Phase B claim primitives (claimedByDeviceId, claimedAt), retry budget (attemptCount, maxAttempts, lastAttemptAt, nextRetryAt), and the bounded attemptHistory (≤5 entries). Rule-level invariants enforce: attemptCount is int && ≥ 0 && ≤ maxAttempts; attemptHistory is list && size ≤ 5. The full status-transition matrix is enforced on the client (runner state machine) — rules cover shape + bounds, not transitions, because the transition graph would push the rules file past readability.

Special paths

Anonymous reads

Two paths permit unauthenticated reads:

  • sms_advertisements — promotional content shown to all visitors. Public reads, admin writes.
  • sms_guest_quota — pre-login quota mirror. Public reads (no PII stored), shape-validated anonymous writes.

Anonymous writes

One path: sms_guest_quota's quota-bump rule. The bump is heavily constrained: the doc id must equal data.guestId, the count must be monotonic non-decreasing, capped at 10, and only the four allowed keys may be touched.

The contact-form path (sms_contact_submissions) accepts both anonymous and authenticated creates but enforces a strict schema (isValidContactSubmissionCreate()). The userId field, if present, must equal auth.uid — so anonymous submitters cannot claim someone else's identity.

Public-read admin-write

sms_advertisements is the only such path: ads are public reads to support the marketing surfaces.

Health-gated fan-out

A batch's assignedDeviceIds cannot include any device with status == 'flagged'. The areAllAssignedDevicesHealthy() helper walks the assigned array (up to 10 entries) and get()s each device doc. Any flagged device fails the entire write — admin must clear the flag (manually or via auto-clear) before the batch can fan out.

Default deny

The final block — match /{document=**} { allow read, write: if false; } — denies anything not explicitly allowed above. New collections must add their own match rule. Forgetting the explicit rule means the collection is denied by default until you add it.

Index dependencies

Rule helpers like isAssignedDeviceOwner() and isBatchAssignedToCaller() rely on get() and exists() against other documents. Those reads count against Firestore's per-request read budget. The 10-device cap exists in part to keep this budget tractable — beyond 10 lookups per rule evaluation, Firestore's documented limit on cross-document reads in a single rule starts to bite.

How to validate rules changes

Exercise a rules change against the local Firestore emulator before deploying. Deploy rules and indexes only after the change behaves as expected:

yarn firebase:emulators # start the Firestore emulator
firebase deploy --only firestore:indexes # AFTER validating — never use --only firestore (it deletes indexes)

Where to dig deeper