Skip to content

Deploy M-1 Firestore create rules (once client getIdToken(true) fix is released) #67

Description

@BurningAXE

Summary

Deploy the M-1 Firestore Security Rules (bind reporter/author to the caller on signal & comment create, block anonymous creation server-side, add basic size/type validation). The rules are written, reviewed, and validated, but deployment is blocked on a client change that must ship in an app release first.

Prod (help-a-paw-dev) currently runs the pre-M-1 (H-1) rules. M-1 was briefly deployed and rolled back on 2026-07-23 after an on-device test exposed the regression below.

Background: the regression that blocks this

M-1 gates create on request.auth.token.email_verified == true (chosen over sign_in_provider != 'anonymous', which is unreliable because the app upgrades anonymous accounts in place via linkWithCredential — the token's sign_in_provider stays 'anonymous' after linking).

email_verified is baked into the ID token at mint time. A user who signs up + verifies (or does an in-place anon→email/Google upgrade) in one session keeps a stale email_verified: false token, so the rule denies their first creates with PERMISSION_DENIED until the token is re-minted. user.reload() does not refresh it, and an app restart within ~1h reuses the still-valid cached token. Device-confirmed: create denied just-after-verify and after restart; succeeded only after sign-out/sign-in.

✅ Blocked by: client token-refresh fix (ship in an app release first)

Force await user.getIdToken(true) after any account-state change that flips email_verified, so the token Firestore reads is current. Two spots:

1. lib/src/widgets/email_verification_page.dart — in _checkEmailVerified(), after verification is detected:

if (refreshedUser?.emailVerified ?? false) {
  // reload() updates the User object but NOT the cached ID token that
  // Firestore rules read. Force-refresh so email_verified flips to true
  // immediately; otherwise the freshly-verified user's first writes are
  // denied until the token organically refreshes (~1h) or they re-login.
  await refreshedUser!.getIdToken(true);
  _timer?.cancel();
  if (mounted) {
    context.go(Routes.completeProfile);
  }
}

2. lib/src/widgets/sign_in_page.dart — in _onCredentialLinked(), the Google branch (skips the verify screen, goes straight into the app):

} else {
  // Linked Google (already verified) — user goes straight into the app. The
  // in-place link keeps the anonymous-minted ID token, whose email_verified
  // claim is still false; force-refresh it so Firestore rules don't deny this
  // real user until the token organically refreshes (~1h) or they re-login.
  await user.getIdToken(true);
  if (context.mounted) context.push(Routes.completeProfile);
}

Then: deploy these M-1 rules

Full firestore.rules to deploy (only after the client fix is in a released build):

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    // DocumentReference to the caller's own user doc. Single source of truth
    // for every "is this the caller" identity check below.
    function userDoc() {
      return /databases/$(database)/documents/users/$(request.auth.uid);
    }

    // True only for durably authenticated callers with a verified email
    // (email/password once verified, or Google, which is auto-verified), NOT the
    // automatic anonymous sessions the app opens at startup.
    //
    // We gate on email_verified rather than sign_in_provider because the app
    // upgrades anonymous accounts IN PLACE (linkWithCredential, same UID). That
    // links a new provider but does not re-authenticate, so the session token's
    // `sign_in_provider` stays 'anonymous' after the upgrade — using it here
    // would lock out every upgraded (i.e. real) user. `email_verified` is
    // derived from account state at token-mint, so it reflects the upgrade and
    // also mirrors the app's router gate that blocks unverified users from the
    // create UI.
    function isNotAnonymous() {
      return request.auth != null
             && request.auth.token.email_verified == true;
    }

    // The signal's reporter (owner), compared by DocumentReference path.
    // Used by both the prod and test signal update/delete rules so the
    // security-critical ownership check lives in exactly one place.
    function isSignalReporter() {
      return resource.data.reporter == userDoc();
    }

    // The caller is the reporter of the parent signal at {coll}/{signalId}.
    // Backs the comment-delete cascade for both the prod and test collections,
    // so that ownership check lives in one place instead of being inlined twice.
    function isParentSignalReporter(coll, signalId) {
      return get(/databases/$(database)/documents/$(coll)/$(signalId)).data.reporter
             == userDoc();
    }

    // Validates a signal create (M-1): non-anonymous caller, reporter pinned to
    // the caller (no impersonation), and basic type/size bounds on the
    // content fields to curb abuse and read/storage-cost inflation.
    function isSignalCreate() {
      return isNotAnonymous()
             && request.resource.data.reporter == userDoc()
             && request.resource.data.title is string
             && request.resource.data.title.size() > 0
             && request.resource.data.title.size() <= 300
             && request.resource.data.description is string
             && request.resource.data.description.size() <= 10000
             && request.resource.data.signalType is int
             && request.resource.data.signalType >= 0
             && request.resource.data.signalType <= 6;
    }

    // Validates a comment create (M-1): non-anonymous caller and author pinned
    // to the caller. Covers both shapes — user text comments and the
    // status_change system comments (which carry no `text`), so the text-size
    // cap only applies when a `text` field is present.
    function isCommentCreate() {
      return isNotAnonymous()
             && request.resource.data.author == userDoc()
             && (
               !('text' in request.resource.data)
               || (request.resource.data.text is string
                   && request.resource.data.text.size() > 0
                   && request.resource.data.text.size() <= 2000)
             );
    }

    // Non-reporters may ONLY advance a signal's status (the volunteer flow),
    // and must self-stamp lastUpdatedBy so it can't be spoofed to another user.
    function isStatusOnlyUpdate() {
      return request.resource.data.diff(resource.data).affectedKeys()
               .hasOnly(['status', 'lastUpdatedBy'])
             && request.resource.data.lastUpdatedBy == userDoc()
             && request.resource.data.status is int
             && request.resource.data.status >= 0
             && request.resource.data.status <= 2;
    }

    // Users collection - private profile (tokens, location, prefs, phone).
    // Owner-only: never expose to other users.
    match /users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // User live location - kept separate from the user doc so high-frequency
    // location writes don't trigger the token-dedupe Cloud Function. Owner-only;
    // the notification fan-out reads it via the Admin SDK (bypasses rules).
    match /userLocations/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }

    // Public profiles - just the display name, readable by any signed-in user
    // (incl. anonymous) so reporter/comment-author names resolve for everyone.
    // Writable only by the owner. On account deletion this is overwritten with
    // "Deleted user" so erasure propagates to all signals/comments dynamically.
    match /publicProfiles/{userId} {
      allow read: if request.auth != null;
      allow write: if request.auth != null && request.auth.uid == userId;
    }

    // Signals collection - public read, authenticated write
    match /signals/{signalId} {
      allow read: if true;  // Public read - signals are public data
      allow create: if isSignalCreate();
      // Reporter may edit any field; anyone else signed in may only advance the
      // status (self-stamping lastUpdatedBy). Prevents non-reporters from
      // rewriting reporter/title/phone/photos or taking over a signal.
      allow update: if request.auth != null
                    && (isSignalReporter() || isStatusOnlyUpdate());
      // Only the signal's reporter may delete it
      allow delete: if request.auth != null && isSignalReporter();

      // Comments subcollection
      match /comments/{commentId} {
        allow read: if true;  // Public read
        allow create: if isCommentCreate();
        // The signal's reporter may delete comments (enables delete-signal cascade)
        allow delete: if request.auth != null
                      && isParentSignalReporter('signals', signalId);
      }
    }

    // Test signals collection - same rules as signals
    match /signals_test/{signalId} {
      allow read: if true;
      allow create: if isSignalCreate();
      // Same rules as prod signals (see helpers above).
      allow update: if request.auth != null
                    && (isSignalReporter() || isStatusOnlyUpdate());
      allow delete: if request.auth != null && isSignalReporter();

      match /comments/{commentId} {
        allow read: if true;
        allow create: if isCommentCreate();
        allow delete: if request.auth != null
                      && isParentSignalReporter('signals_test', signalId);
      }
    }

    // Allow collection group queries for comments
    match /{path=**}/comments/{commentId} {
      allow read: if request.auth != null;
    }

    // Feedback collection - anyone can submit, only admins can read
    match /feedback/{feedbackId} {
      allow create: if request.resource.data.message is string
                    && request.resource.data.message.size() > 0
                    && request.resource.data.message.size() <= 1000
                    && request.resource.data.type in ['general', 'bug', 'feature', 'other']
                    && (!('email' in request.resource.data) || request.resource.data.email == null || request.resource.data.email is string);
      allow read, update, delete: if false;  // Only accessible via Admin SDK/Console
    }
  }
}

Deploy & verify

  1. Confirm the client getIdToken(true) fix is in the released app build (existing installs won't get it otherwise).
  2. firebase deploy --only firestore:rules --project help-a-paw-dev (⚠️ help-a-paw-dev is production).
  3. Device-verify in test mode (tap the app title 7× → writes to signals_test):
    • Fresh anon → blocked from creating (client dialog).
    • Register a new account via helpapaw.qa+<alias>@gmail.com (true in-place anon→email link), verify email → create a signal → succeeds (no PERMISSION_DENIED).
    • Google in-place upgrade → create → succeeds.
  4. Commit firestore.rules to git (the M-1 change is currently uncommitted).

Notes / non-blocking follow-ups (from code review)

  • Size caps have no matching client maxLength → long-but-legit input gets an opaque PERMISSION_DENIED. Consider adding maxLength on the create form.
  • Size validation is on create only; the reporter update path is unbounded (bypassable). Acceptable for now (self-owned docs).
  • Empty comment now rejected server-side but the send button has no empty-text guard.
  • signalType <= 6 hardcodes the enum bound; bump if a 7th type is added.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions