-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathfirestore.rules
More file actions
970 lines (917 loc) · 52.5 KB
/
Copy pathfirestore.rules
File metadata and controls
970 lines (917 loc) · 52.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// The caller's own /users doc, as a DocumentReference. Signals and comments
// store their owner as a reference to this doc, so every ownership check
// below compares against it.
function userDoc() {
return /databases/$(database)/documents/users/$(request.auth.uid);
}
// 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 holds the moderator role (master spec 3.6.1).
//
// A server-only DOCUMENT, deliberately not a custom auth claim. Moderator
// *actions* all run through callable Cloud Functions, which re-check this
// same document with the Admin SDK — so the claim would only ever decide
// whether the client draws a button, at the cost of a second copy of the
// value that can drift. It would also inherit the ID token's refresh
// window: a revoked moderator would keep their powers until the token
// expired, up to an hour. Reading the document instead makes both grant
// and revoke take effect on the very next request.
//
// Costs one document read per evaluation, which is why every call site
// below places it LAST in an `||` chain — the owner check short-circuits
// first, so the ordinary path never pays for it.
function isModerator() {
return request.auth != null
&& exists(/databases/$(database)/documents/moderators/$(request.auth.uid));
}
// Comments are locked on the parent signal at {coll}/{signalId} (master
// spec 18.3, "Lock comments").
//
// Nested `get()` with defaults so a signal with no `moderation` map at all
// — which is every signal written before this feature — reads as unlocked
// rather than erroring the whole rule and denying every comment.
function isCommentsLocked(coll, signalId) {
return get(/databases/$(database)/documents/$(coll)/$(signalId))
.data.get('moderation', {}).get('commentsLocked', false) == true;
}
// The `moderation` map is written ONLY by the Cloud Functions (Admin SDK,
// bypasses rules). No client may touch it — least of all the reporter.
//
// This is load-bearing, not defensive: the reporter's own update branch
// (isSignalReporter) accepts *any* field at any value, so without this a
// reporter could clear `moderation.commentsLocked` or delete the warning
// label a moderator put on their signal, straight from a patched client.
// The lock would be decoration.
function isNotTouchingModeration() {
return !request.resource.data.diff(resource.data)
.affectedKeys().hasAny(['moderation']);
}
// The caller currently holds this case (master spec 4.5).
//
// The DEFAULT ARGUMENT is the "absent means the reporter" derivation, and it
// is permanent rather than a migration step: builds released before case
// ownership keep creating signals with no `caseHolder`, so no ruleset can
// assume the field is there, and nothing is backfilled.
//
// A RELEASED case stores an explicit null, which equals no userDoc() — so a
// released case correctly has no holder rather than falling back to the
// reporter, who is the one person who may have just stepped away from it.
// That distinction is the whole point of storing null rather than deleting
// the field, and collapsing the two is the mistake this comment exists to
// prevent.
function isCaseHolder() {
return resource.data.get('caseHolder', resource.data.reporter) == userDoc();
}
// `caseHolder` is written ONLY by the caseOwnership Cloud Function (Admin
// SDK, bypasses rules), plus once at creation via isSignalCreate().
//
// Load-bearing for exactly the reason isNotTouchingModeration() is, and
// applied to BOTH update branches for the same reason: the reporter's branch
// accepts any field at any value, so without this a reporter could never be
// handed off from — they would simply write themselves back in — and any
// signed-in user on a patched client could seize a stranger's case by
// writing one field. The timeline would show no transfer, because no event
// was written.
function isNotTouchingOwnership() {
return !request.resource.data.diff(resource.data)
.affectedKeys().hasAny(['caseHolder']);
}
// `holderActiveAt` is a CLOCK, so it is pinned to the server's.
//
// The staleness rule that stops a case deadlocking behind a silent holder
// reads this field, which means a holder free to choose its value could keep
// a case they have abandoned forever. Allowed on either branch — the holder
// stamps it as part of an ordinary status change — but never at a value the
// caller picked.
function isValidHolderStamp() {
return !request.resource.data.diff(resource.data)
.affectedKeys().hasAny(['holderActiveAt'])
|| request.resource.data.holderActiveAt == request.time;
}
// The caller holds the case on the parent signal at {coll}/{signalId}.
// Same shape and same reasoning as isParentSignalReporter, including the
// absent-means-reporter default; backs the takeoverRequests rules, where a
// holder must not be able to file a request against themselves.
function isParentCaseHolder(coll, signalId) {
// `let` so the document is fetched ONCE. Spelling the get() out twice —
// for the value and for the default — is two billed document reads on
// every takeover-request write, for one answer.
let parent = get(/databases/$(database)/documents/$(coll)/$(signalId)).data;
return parent.get('caseHolder', parent.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): reporter pinned to the caller (no
// impersonation) plus basic type/size bounds on the content fields, to curb
// content abuse and read/storage-cost inflation.
//
// NOTE: this deliberately does NOT block anonymous callers yet — see the
// comment on the signals `create` rule below (HelpAPaw/Flutter#67).
function isSignalCreate() {
return request.auth != null
&& 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
// `signalType` is deliberately NOT required, and not bounded
// either. Signal types were folded into the help-tag vocabulary
// and nothing writes the field any more, but builds released
// before that still send it — mandating or range-checking a
// retired field would only ever break an old client.
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType()
&& isValidInitialCaseHolder();
}
// The reporter is the initial case holder (master spec 4.5).
//
// Optional-if-absent for the same reason as isValidUrgency: builds released
// before case ownership create signals with no `caseHolder`, and readers
// derive one. Present-and-wrong is what this rejects — a signal cannot be
// created already assigned to somebody else, which would be a way to write
// the field without going through the callable or leaving a timeline entry.
//
// `holderActiveAt` is REJECTED outright on create, and that is a security
// check rather than tidiness: isValidHolderStamp() is wired into the two
// update rules only, so without this clause a patched client could create a
// signal stamped with a far-future `holderActiveAt`. `isHolderStale` then
// computes a negative age forever, and the staleness escape hatch — the
// thing that stops a case deadlocking behind a holder who stops answering —
// is permanently disabled for that signal, silently.
//
// The server falls back to `createdAt` until the holder first acts, so
// omitting it costs nothing.
function isValidInitialCaseHolder() {
return !('holderActiveAt' in request.resource.data)
&& (!('caseHolder' in request.resource.data)
|| request.resource.data.caseHolder == userDoc());
}
// Bounds the help tags, but does NOT require the field — same reasoning as
// isValidUrgency. Builds released before the tag system are still in the
// wild and create signals with none; the fan-out defaults those to the
// fallback tag, so they still reach people. Requiring it here would break
// signal creation for everyone who has not updated.
//
// Tighten to mandatory only once adoption of the tagged build is high
// enough — that is step 3 of the rollout, and it is a production deploy.
// This did NOT change when signal types were retired: tags are now the only
// thing describing what a signal needs, which makes requiring them more
// tempting and no less breaking. An old build that cannot create a signal
// at all is a worse outcome than one whose signals default to `rescue`.
//
// The size cap is a reach limit as much as a priority limit: tagging a
// signal with the whole vocabulary would make it match every user. The
// codes themselves are NOT validated against an allow-list, deliberately —
// a newer client may introduce a code this deployed ruleset has never heard
// of, and rejecting it would break the new build instead of the old one.
function isValidHelpNeededTags() {
return !('helpNeededTags' in request.resource.data)
|| (request.resource.data.helpNeededTags is list
&& request.resource.data.helpNeededTags.size() > 0
&& request.resource.data.helpNeededTags.size() <= 3);
}
// Optional for the same reason, and left unconstrained beyond being a
// string so a future species cannot be rejected by an old ruleset.
function isValidAnimalType() {
return !('animalType' in request.resource.data)
|| (request.resource.data.animalType is string
&& request.resource.data.animalType.size() > 0
&& request.resource.data.animalType.size() <= 32);
}
// Bounds the urgency level, but does NOT require the field.
//
// Optional on purpose: builds released before the urgency system are still
// in the wild and create signals with no `urgency` at all. Making it
// mandatory would break signal creation for every user who has not
// updated, which is not a trade worth making for a field the client
// back-fills on read anyway.
function isValidUrgency() {
return !('urgency' in request.resource.data)
|| (request.resource.data.urgency is int
&& request.resource.data.urgency >= 0
&& request.resource.data.urgency <= 2);
}
// Validates a comment create (M-1): author pinned to the caller. Covers all
// three comment shapes — user text comments, and the `status_change` /
// `urgency_change` system comments (which carry no `text`) — so the text
// bounds only apply when a `text` field is present.
//
// Takes the collection so the lock check can name the parent signal, the
// same way isParentSignalReporter does. That `get()` is one extra read per
// comment write; comments are low-volume, and it is the difference between
// a real lock and a hidden text field. It also denies comments on a
// QUARANTINED signal for free: the parent document no longer exists, so
// the get() fails and the write is refused.
function isCommentCreate(coll, signalId) {
return request.auth != null
&& 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)
)
&& !isCommentsLocked(coll, signalId);
}
// Validates a signal-timeline event create (master spec 4.6).
//
// Events live in their own subcollection rather than in `comments` because
// they answer to different rules: a comment is user-authored and its text is
// the payload, while an event is a record of something that happened to the
// signal and its shape is fixed per `type`. Keeping them apart is what lets
// this function validate a CLOSED vocabulary instead of accumulating another
// "only when present" clause per event type, the way isCommentCreate() had
// to for the two system shapes it still has to tolerate.
//
// The type list is duplicated from SignalEventType (lib/src/models/signal_event.dart)
// and guarded by test/signal_event_vocabulary_guard_test.dart. A type the app
// can write but the rules reject fails loudly (the write is denied); a type
// the rules accept but the app cannot read fails SILENTLY — the row just
// never appears in anyone's history.
//
// A `status_change` event does not by itself authorise the status change:
// the signal write travels through isCaseHolderUpdate() separately.
function isSignalEventCreate() {
return request.auth != null
&& request.resource.data.actor == userDoc()
&& request.resource.data.createdAt is timestamp
&& isValidEventNote()
&& (
(request.resource.data.type == 'status_change'
&& isValidLevel('oldStatus') && isValidLevel('newStatus'))
|| (request.resource.data.type == 'urgency_change'
&& isValidLevel('oldUrgency') && isValidLevel('newUrgency'))
);
}
// NOTE the type NOT in that list: `ownership_transfer`. It is SERVER-ONLY
// (SignalEventType.serverOnly) — the caseOwnership callable writes it through
// the Admin SDK, which bypasses rules entirely, so omitting it here costs
// nothing and buys a real property: nobody can forge a timeline entry saying
// they took responsibility for a case. The drift guard therefore compares
// this list against SignalEventType.clientCodes, not allCodes. Do NOT add it
// here to make a failing guard pass.
// The update note is MANDATORY on an event (spec 4.6: "Every status change
// requires an update note"), unlike `text` on a comment, which is optional
// because the legacy system shapes carry none.
//
// 500 is mirrored by the input formatter on the note dialog — see the
// field-length invariant in docs/SPECIFICATION.md 12.
function isValidEventNote() {
return request.resource.data.note is string
&& request.resource.data.note.size() > 0
&& request.resource.data.note.size() <= 500;
}
// A status or urgency code. Same 0..2 bound as the signal fields they
// describe, so an event can never claim a transition the signal itself
// could not hold.
// `get` with an out-of-range default so an absent field fails the bound
// rather than needing a separate `in` check.
function isValidLevel(field) {
let value = request.resource.data.get(field, -1);
return value is int && value >= 0 && value <= 2;
}
// Validates the display name on a publicProfiles write (L-2). Bounds the
// length and rejects control characters — a newline or a NUL in a name that
// is rendered next to every signal and comment is only ever abuse.
//
// `matches()` is a whole-string RE2 match. The pattern reads as "any run of
// non-control characters containing at least one that isn't a space", so it
// also rejects "" and whitespace-only names, which would render as a blank
// author and read as a deleted/unknown user.
function isValidProfileName() {
return request.resource.data.name is string
&& request.resource.data.name.size() <= 100
&& request.resource.data.name
.matches('[^\\x00-\\x1f]*[^\\x00-\\x20][^\\x00-\\x1f]*');
}
// The CASE HOLDER's coordination powers (master spec 4.5, 4.2, 5.2): the
// status, the urgency and the help-needed tags, plus the two stamps that
// travel with them. Everything else about the signal stays the reporter's.
//
// This REPLACED isStatusOnlyUpdate(), which let ANY signed-in user change
// ANY signal's status. That is why `urgency` used to be excluded from this
// allow-list, and why that exclusion was described — here, and in
// SPECIFICATION 4.6 and 5.1 — as the entire enforcement of "only the case
// holder may mark a signal Red": with no ownership concept, the reporter was
// the only stand-in for a case holder the app had.
//
// Now there is one. The gate moved from the FIELD LIST to isCaseHolder(), so
// `urgency` and `helpNeededTags` being here is the spec being implemented
// rather than the old rule being weakened — master spec 5.2 names the case
// holder explicitly, and 4.2 gives them the tags. **The load-bearing clause
// is now isCaseHolder(); do not drop it to "simplify" this function.**
//
// lastUpdatedBy is still self-stamped so it can't be pointed at someone else
// (handleSignalUpdated uses it to decide whom NOT to notify), and
// holderActiveAt is pinned to the server clock by isValidHolderStamp on the
// rule itself.
// **THIS BREAKS ALREADY-RELEASED BUILDS, DELIBERATELY.** Every shipped
// client writes `{status, lastUpdatedBy}` for any signed-in user, which the
// old rule allowed; here it is denied on any signal the caller did not
// report and does not hold. Those builds have no ownership UI, so there is
// no claim path and the user just gets "Failed to update status".
//
// Called out explicitly, the way the M-1 / #67 comments above are, because
// the mitigation is a DEPLOY ORDER and not a code change: functions first,
// then the app release that offers claim-to-act, then this ruleset — never
// this ruleset first. Tracking: HelpAPaw/Flutter#71, and SPECIFICATION 4.8.
function isCaseHolderUpdate() {
return isCaseHolder()
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'urgency', 'helpNeededTags',
'lastUpdatedBy', 'holderActiveAt'])
&& request.resource.data.lastUpdatedBy == userDoc()
&& request.resource.data.status is int
&& request.resource.data.status >= 0
&& request.resource.data.status <= 2;
}
// Validates a takeover request (master spec 4.5, "case ownership can be
// transferred if someone else takes responsibility").
//
// The DOCUMENT ID IS THE RATE LIMIT — it is the requester's uid, create is
// allowed and update is not, so one person can have at most one live request
// per signal. Same trick as isReportCreate below.
//
// Unlike signal and comment creation, this DOES block anonymous callers.
// That is not an inconsistency: the M-1 gap exists only because already
// released builds create signals anonymously, and nothing has ever written
// this collection, so a new surface can be strict from its first day.
// `sign_in_provider` rather than `email_verified` deliberately — the latter
// is what HelpAPaw/Flutter#67 is blocked on.
// Whether an ANSWERED takeover request may be filed again.
//
// A decline should not be permanent — a case looks very different two weeks
// later, and a volunteer told "no, I have this" in the first hour may be the
// right person once the holder has moved on. But re-asking has to cost
// something, because every request notifies the holder, and a decline is
// exactly the moment somebody might start nagging.
//
// One day. Long enough that it cannot be used to pester, short enough that
// it never blocks a genuine change of circumstances.
//
// The value is mirrored by `CaseOwnershipService.reaskCooldown`, which the
// UI needs to say *when* the offer can be made again, and guarded by
// `test/takeover_cooldown_guard_test.dart` — see the field-length invariant
// in docs/SPECIFICATION.md 12 for why that pairing is always guarded.
function isAfterReaskCooldown() {
return resource.data.status != 'pending'
&& resource.data.resolvedAt is timestamp
&& request.time > resource.data.resolvedAt + duration.value(1, 'd');
}
// Withdrawing your own offer, which is an UPDATE and not a delete.
//
// It has to be, or the cooldown above is decoration: if withdrawing removed
// the document, `create` would be unconstrained again and
// withdraw → re-file → withdraw → re-file is an unlimited loop, notifying
// the holder every time. Marking it `withdrawn` leaves the slot occupied, so
// asking again costs the same cooldown as being declined does.
//
// `resolvedAt` is pinned to `request.time` for the same reason it is on the
// holder's stamp — a value the requester could choose is a cooldown they
// could skip. `handleTakeoverResolved` ignores this status, so nobody is
// notified that somebody changed their mind.
function isTakeoverWithdraw(requesterId) {
return request.auth != null
&& request.auth.uid == requesterId
&& resource.data.status == 'pending'
&& request.resource.data.status == 'withdrawn'
&& request.resource.data.resolvedAt == request.time
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status', 'resolvedAt']);
}
function isTakeoverRequestCreate(coll, signalId, requesterId) {
return request.auth != null
&& request.auth.uid == requesterId
&& request.auth.token.firebase.sign_in_provider != 'anonymous'
&& request.resource.data.requester == userDoc()
&& request.resource.data.status == 'pending'
&& isValidEventNote()
&& request.resource.data.createdAt is timestamp
&& request.resource.data.keys()
.hasOnly(['requester', 'status', 'note', 'createdAt'])
&& !isParentCaseHolder(coll, signalId);
}
// Users collection - private profile (tokens, location, prefs, phone).
// Owner-only: never expose to other users.
//
// NOTE: rules do NOT cascade into subcollections. This block covers the user
// document only — `users/{uid}/notifications/{id}` needs its own match below,
// and so would any future subcollection.
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
// create/update and delete are split on purpose. `isValidHelperPrefs`
// dereferences `request.resource.data`, which is **null on a delete** — a
// combined `allow write` therefore errors and denies every delete,
// including the owner's own. That breaks `detachAnonymousData`, which
// deletes this doc and `userLocations/{uid}` in one try block: the first
// delete throws, the second never runs, and the abandoned uid keeps a live
// location doc that the fan-out still treats as a candidate.
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& isValidHelperPrefs();
allow delete: if request.auth != null && request.auth.uid == userId;
}
// Size caps on the tag/species preference lists.
//
// Only the owner can write here, so this is not an authorization boundary —
// it is a bound on how far one account can inflate the fan-out's work.
// Those lists are read for every candidate on every signal, and nothing
// else stops a client writing ten thousand entries into one.
//
// Codes are not checked against an allow-list, for the same reason as
// isValidHelpNeededTags: a newer app build must not be rejected by an older
// deployed ruleset. An unrecognised code simply never matches.
function isValidHelperPrefs() {
return isBoundedCodeList('helperTags', 32)
&& isBoundedCodeList('animalTypes', 32);
}
// `field` under notificationPreferences is absent, or a list of at most
// `max` entries. Absent is always allowed: every writer of this document
// uses a merged partial write, so most updates touch none of these.
function isBoundedCodeList(field, max) {
return !('notificationPreferences' in request.resource.data)
|| !(field in request.resource.data.notificationPreferences)
|| (request.resource.data.notificationPreferences[field] is list
&& request.resource.data.notificationPreferences[field].size() <= max);
}
// In-app notification inbox, one document per notification per recipient.
// Written server-side by the fan-out (Admin SDK, bypasses rules) and
// client-side by the arrival catch-up (NearbySignalChecker), which runs in a
// headless isolate.
match /users/{userId}/notifications/{notificationId} {
allow read: if request.auth != null && request.auth.uid == userId;
// The only client-side writer is the catch-up, and it only ever produces
// `nearby_signal`. Pinning the type stops a client fabricating a
// `status_change` entry it was never sent. The size caps are the real
// security value: without them an owner-only collection is a free-storage
// vector. `expiresAt` is required or the document would outlive the TTL
// policy forever.
allow create: if request.auth != null
&& request.auth.uid == userId
// Both field shapes are allowed, and neither is required.
// Builds released before the tag vocabulary write
// `signalType`; newer ones write `helpNeededTags`. A phased
// release means both are in the wild for months, and this
// rule is the *client* write path for the arrival catch-up
// — rejecting the old shape makes that inbox entry vanish
// silently, because NearbySignalChecker swallows the error.
// Tighten to helpNeededTags-only once old builds are gone.
// Tracking: HelpAPaw/Flutter#70.
&& request.resource.data.keys().hasOnly([
'type', 'title', 'body', 'read', 'signalId',
'signalTitle', 'helpNeededTags', 'signalType',
'testMode', 'createdAt', 'expiresAt'
])
&& request.resource.data.type == 'nearby_signal'
&& request.resource.data.read == false
&& request.resource.data.signalId is string
&& request.resource.data.signalId.size() <= 200
&& request.resource.data.title is string
&& request.resource.data.title.size() <= 300
&& request.resource.data.body is string
&& request.resource.data.body.size() <= 1000
&& request.resource.data.signalTitle is string
&& request.resource.data.signalTitle.size() <= 300
// Bounded if present, never required — see the allow-list
// above. Codes are not checked against a list: a newer
// client may know one this deployed ruleset does not, and
// rejecting it would break the new build rather than the
// old one. Same reasoning as isValidHelpNeededTags.
&& (!('helpNeededTags' in request.resource.data)
|| (request.resource.data.helpNeededTags is list
&& request.resource.data.helpNeededTags.size() <= 3))
&& (!('signalType' in request.resource.data)
|| request.resource.data.signalType is int)
&& request.resource.data.testMode is bool
&& request.resource.data.createdAt is timestamp
&& request.resource.data.expiresAt is timestamp;
// Diff-based: the page only ever flips `read`, so a client can't rewrite
// the title/body/signalId of a notification after the fact.
allow update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['read'])
&& request.resource.data.read is bool;
allow delete: 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;
}
// Unread-notification counter, source of the iOS app badge. Top-level for
// the same reason as userLocations: `onUserTokensWritten` fires on every
// `users/{uid}` write, so a counter on the user doc would cost one function
// invocation per recipient per notification.
//
// The value is advisory — it drifts on Cloud Function retries and TTL
// deletions — and the client repairs it with a count() aggregation on
// resume. That is why the owner may write it directly.
match /userCounters/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
allow write: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['unread', 'updatedAt'])
&& request.resource.data.unread is int
&& request.resource.data.unread >= 0;
}
// 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} {
// Single-document reads only (L-2). The app resolves names one uid at a
// time (PublicProfileService.getName), so denying `list` costs it nothing
// and stops the entire user base being enumerated from one query. Do NOT
// widen this back to `read` — that grants `list` again.
allow get: if request.auth != null;
allow list: if false;
// The only field the app ever writes here is `name`. `deleted`/`deletedAt`
// are tombstone fields written by deleteAccount through the Admin SDK,
// which bypasses rules — so restricting the caller to `name` costs
// nothing and stops a user clearing their own "Deleted user" tombstone.
allow create: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['name'])
&& isValidProfileName();
// Diff-based (not `keys()`) so a name edit on a doc that already carries
// the tombstone fields isn't rejected for merely containing them.
allow update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.diff(resource.data)
.affectedKeys().hasOnly(['name'])
&& isValidProfileName();
allow delete: 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
// M-1, partial: binds `reporter` to the caller and bounds the content
// fields. The remaining half of M-1 — blocking anonymous callers
// server-side — is NOT here yet: it has to gate on
// `request.auth.token.email_verified`, which is baked into the ID token at
// mint time, so a user who verifies (or upgrades an anonymous account in
// place) mid-session keeps a stale `false` claim and gets denied. The
// client fix that force-refreshes the token (633da3b) is on dev but not in
// any released build, so that clause stays out until it ships.
// Tracking: HelpAPaw/Flutter#67.
allow create: if isSignalCreate();
// Reporter may edit any field; the CASE HOLDER may change the coordination
// fields (status, urgency, tags). Nobody else may write the signal at all
// — a volunteer who wants to move a case claims it first, through the
// caseOwnership callable (master spec 4.5, "the current case holder can
// update status").
//
// `isValidUrgency()` applies to BOTH branches. The reporter branch
// otherwise accepts any field at any value, and an out-of-range urgency
// is not a client-side cosmetic problem: the server reads it raw, so
// `urgency: 42` makes every write look like an escalation
// (`42 > 2`) and wakes every subscriber with a "Updated" push.
//
// The tag/species validators apply to both branches for the same reason,
// and the cap especially: `helpNeededTags` is a *reach* limit, so leaving
// update unvalidated means a reporter can create a compliant signal and
// then widen it to the whole vocabulary, matching every user in the
// fan-out's set intersection. The edit screen writes both fields on
// update, so this is the path that field actually travels.
//
// `isNotTouchingModeration()` applies to BOTH branches — see its doc
// comment. The reporter branch is the one that matters.
allow update: if request.auth != null
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType()
&& isNotTouchingModeration()
&& isNotTouchingOwnership()
&& isValidHolderStamp()
&& (isSignalReporter() || isCaseHolderUpdate());
// 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('signals', signalId);
// The signal's reporter may delete comments (enables delete-signal cascade).
//
// **The worse half of the #68 gap, and not an audit problem.** There is
// no author-delete rule anywhere on this collection, so the standing
// effect is that a comment's own author cannot delete their comment but
// the signal's reporter can delete ANYONE's — quietly, one at a time,
// with no audit row. Removing every critical comment on your own case
// is the power master spec §18.3 deliberately routes through
// `moderateAction.deleteComment`, which demands a note, writes an audit
// entry and refuses your own content.
//
// Retired together with the two rules below — see the events block for
// why the flip is a separate deploy. Tracking: HelpAPaw/Flutter#68.
allow delete: if request.auth != null
&& isParentSignalReporter('signals', signalId);
}
// Signal timeline (master spec 4.6). See isSignalEventCreate() for why
// this is not in `comments`.
match /events/{eventId} {
allow read: if true; // Public read, like the signal it describes
allow create: if isSignalEventCreate();
// An event is a record of what happened; nobody edits history.
allow update: if false;
// KNOWN GAP, now with a fix built and waiting on a deploy order.
//
// The reporter can delete individual events, so the history is
// tamper-evident at best. This only ever existed because the
// delete-signal cascade ran on the CLIENT and had to be able to empty
// the subcollection, or deleting a signal orphaned it forever.
//
// **That cascade is gone.** `signalRemoval` (functions/src/removeSignal.ts)
// moves the document server-side and `purgeRemoval` erases the
// descendants with an Admin SDK `recursiveDelete`, which bypasses these
// rules entirely. Nothing in the new client deletes an event.
//
// So this becomes `allow delete: if false` — but NOT in the same deploy
// as the rest of #68, and this is a DEPLOY ORDER, not a code change.
// Every already-released build still runs the old cascade, whose one
// batch covers all three subcollections; denying it makes that batch
// fail atomically, so nothing is deleted and the user gets that
// build's own `failedToDeleteSignal` (a string this branch has since
// renamed) on any signal that has history. Clean — no half-deleted
// signal — but still a broken Delete button until they update. Flip this, and the
// two rules below it, and `isParentSignalReporter`, only once the
// release carrying `signalRemoval` is on the installed base — the same
// sequencing #67 and #71 are waiting on. Tracking: HelpAPaw/Flutter#68.
allow delete: if request.auth != null
&& isParentSignalReporter('signals', signalId);
}
// Takeover requests (master spec 4.5). Doc id IS the requester's uid.
//
// Client-written, unlike the transfer itself, because a request is an
// ordinary user-authored document with no privilege attached — routing it
// through a function would buy nothing, exactly as with `reports`. The
// holder is told about it by the onTakeoverRequested trigger, and
// APPROVING it is the privileged half, which is the callable's job.
match /takeoverRequests/{requesterId} {
// Signed-in rather than public: unlike a comment, this names someone who
// volunteered but has not been accepted, and every reader of this app
// holds at least an anonymous session anyway.
allow read: if request.auth != null;
allow create: if isTakeoverRequestCreate('signals', signalId, requesterId);
// The ONLY client update is re-filing an answered request after the
// cooldown, and it has to satisfy the create validator too — so a
// re-filed request cannot have a different shape from a fresh one, and
// in particular cannot arrive already `approved`. Approving and
// declining stay the caseOwnership callable's job: a client that could
// flip `status` here would claim to have been accepted.
// Ordered cheapest-first, the same convention isModerator() documents:
// withdrawing needs no get() at all, and the cooldown check is free,
// while isTakeoverRequestCreate ends in a billed read of the parent
// signal. Written the other way round, every withdrawal and every
// in-cooldown retry — exactly the traffic the cooldown exists to make
// cheap — pays for that read before being answered.
allow update: if isTakeoverWithdraw(requesterId)
|| (isAfterReaskCooldown()
&& isTakeoverRequestCreate('signals', signalId, requesterId));
// Reporter only, for the delete-signal cascade — a subcollection left
// behind when the parent goes is orphaned forever. The REQUESTER has no
// delete: withdrawing is isTakeoverWithdraw() above, because a delete
// would free the uid slot and walk straight past the cooldown.
//
// The third of the three cascade-only deletes #68 retires; see the
// events block above for why the flip waits on an app release. The
// requester's own side of this rule is unaffected and stays as it is.
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
&& isValidUrgency()
&& isValidHelpNeededTags()
&& isValidAnimalType()
&& isNotTouchingModeration()
&& isNotTouchingOwnership()
&& isValidHolderStamp()
&& (isSignalReporter() || isCaseHolderUpdate());
allow delete: if request.auth != null && isSignalReporter();
match /comments/{commentId} {
allow read: if true;
allow create: if isCommentCreate('signals_test', signalId);
allow delete: if request.auth != null
&& isParentSignalReporter('signals_test', signalId);
}
match /events/{eventId} {
allow read: if true;
allow create: if isSignalEventCreate();
allow update: if false;
// Retired with its production twin — see the #68 deploy-order note there.
allow delete: if request.auth != null
&& isParentSignalReporter('signals_test', signalId);
}
match /takeoverRequests/{requesterId} {
allow read: if request.auth != null;
allow create: if isTakeoverRequestCreate('signals_test', signalId, requesterId);
// Ordered cheapest-first, the same convention isModerator() documents:
// withdrawing needs no get() at all, and the cooldown check is free,
// while isTakeoverRequestCreate ends in a billed read of the parent
// signal. Written the other way round, every withdrawal and every
// in-cooldown retry — exactly the traffic the cooldown exists to make
// cheap — pays for that read before being answered.
allow update: if isTakeoverWithdraw(requesterId)
|| (isAfterReaskCooldown()
&& isTakeoverRequestCreate('signals_test', signalId, requesterId));
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 - only admins can read (via Admin SDK/Console).
// Any signed-in caller (incl. the automatic anonymous app sessions) may
// submit, but (M-2):
// - auth is required — no unauthenticated writes; combined with App Check
// enforcement this closes the open email/cost-abuse vector.
// - userId is pinned to the caller so it can't be spoofed to frame another
// user (the email/rate-limit both key off it).
// - email, when present, must be a syntactically valid address (also
// re-validated in the function before it's used as replyTo).
// Each accepted write triggers onFeedbackCreated, which sends an email and
// enforces a per-user rate limit.
match /feedback/{feedbackId} {
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid
&& 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
&& request.resource.data.email.size() <= 254
&& request.resource.data.email.matches('^[^@ ]+@[^@ ]+[.][^@ ]+$'))
);
allow read, update, delete: if false; // Only accessible via Admin SDK/Console
}
// ---------------------------------------------------------------------
// Moderation (master spec 3.6.1, 18)
// ---------------------------------------------------------------------
// The moderator roster. `{ grantedAt, grantedBy, note? }`.
//
// Granted and revoked ONLY by `functions/scripts/grant_moderator.js` via
// the Admin SDK. No client write path exists — not even a moderator's, and
// least of all the subject's own, which is what stops self-promotion.
//
// `get` on your own document is allowed so the app can decide whether to
// draw the moderation entry point. `list` is denied for the same reason as
// publicProfiles: the roster is exactly the list an attacker would want, to
// know whom to target.
match /moderators/{userId} {
allow get: if request.auth != null && request.auth.uid == userId;
allow list: if false;
allow create, update, delete: if false;
}
// User-submitted reports (master spec 18.1).
//
// The document id is `{reporterUid}_{targetType}_{targetId}` and THAT is
// the rate limit: `create` is allowed, `update` is not, so a second report
// of the same target by the same user collides with the existing document
// and is denied. One report per user per target, with no throttle
// collection to maintain (contrast feedbackThrottle, which throttles an
// email rather than a write).
//
// Reads are moderator-only. A reporter cannot read their own report back —
// deliberate, per spec 18.7: internal moderation records are not visible to
// users, and a report carries an accusation about someone else.
match /reports/{reportId} {
allow get, list: if isModerator();
allow create: if isReportCreate(reportId);
// Status transitions run through the moderation callables so that every
// one leaves an audit entry. A direct client write would not.
allow update, delete: if false;
}
// Pins the reporter to the caller (no framing someone else), pins the
// opening status, and bounds every field.
//
// `reason` is bounded but NOT checked against an allow-list, for the same
// reason as isValidHelpNeededTags: a newer client may know a code this
// deployed ruleset has never heard of, and rejecting it would break the new
// build rather than the old one. An unrecognised reason still reaches a
// moderator, who can read `details`.
//
// The id check is what makes the one-per-target limit real — without it a
// client could write the same report under a fresh random id forever.
function isReportCreate(reportId) {
return request.auth != null
&& request.resource.data.keys().hasOnly([
'targetType', 'targetId', 'signalId', 'collection',
'reason', 'details', 'reporterId', 'reportedUserId',
'status', 'testMode', 'createdAt'
])
&& request.resource.data.targetType in ['signal', 'comment', 'user']
&& request.resource.data.targetId is string
&& request.resource.data.targetId.size() > 0
&& request.resource.data.targetId.size() <= 200
&& request.resource.data.reporterId == request.auth.uid
// Ordered AFTER the type checks above: this concatenates the two
// fields, and doing that to a non-string errors the whole rule.
&& reportId == request.auth.uid + '_'
+ request.resource.data.targetType + '_'
+ request.resource.data.targetId
&& request.resource.data.collection in ['signals', 'signals_test']
&& request.resource.data.reason is string
&& request.resource.data.reason.size() > 0
&& request.resource.data.reason.size() <= 64
&& request.resource.data.details is string
&& request.resource.data.details.size() <= 1000
&& request.resource.data.status == 'open'
// `testMode` is derived from `collection`, not independent of it.
// Validated separately, a patched client could file
// `{collection: 'signals', testMode: true}` — a report against a
// PRODUCTION signal that lands in the test queue, where no
// production moderator is looking. Tying them together is what
// makes the queue's testMode filter mean what it says.
&& request.resource.data.testMode
== (request.resource.data.collection == 'signals_test')
&& request.resource.data.createdAt is timestamp
&& (!('signalId' in request.resource.data)
|| (request.resource.data.signalId is string
&& request.resource.data.signalId.size() <= 200))
&& (!('reportedUserId' in request.resource.data)
|| (request.resource.data.reportedUserId is string
&& request.resource.data.reportedUserId.size() <= 200));
}
// Audit trail of every moderator action (master spec 18.7).
//
// Written exclusively by the moderation callables through the Admin SDK,
// which is the entire point of routing actions through functions instead of
// widening these rules: a client-written audit log is a forgeable one.
// Readable by moderators so the queue can show what has already been done
// to a signal.
match /moderationActions/{actionId} {
allow get, list: if isModerator();
allow create, update, delete: if false;
}
// Quarantined signals — where a hidden signal's document goes.
//
// NO client match on purpose: denied by default, to every caller,
// moderators included. Hiding therefore makes a signal genuinely
// unreadable rather than merely filtered out of the map, which a plain
// `hidden: true` field could never do while `signals` stays publicly
// readable.
//
// match /moderationQuarantine/{id} <- intentionally absent
//
// Note that only the signal DOCUMENT moves. Its `comments` and `events`
// subcollections stay where they are (Firestore keeps subcollections when a
// document is deleted), which is what makes a restore lossless — and also
// means those remain publicly readable to anyone holding the signal id.
// Recorded in docs/SPECIFICATION.md §14.
// Removed signals — where a signal its own REPORTER took down goes (#68).
//
// The same move-not-a-flag shape as quarantine above, and a separate
// collection for three reasons: these expire after
// REMOVED_RETENTION_DAYS and quarantine does not, the reporter restores
// these where only a moderator restores quarantine, and `listQuarantined`
// is a 50-item moderator worklist that ordinary removals would swamp.
//
// Unlike quarantine, this one IS readable — but only by the person whose
// signal it is. Quarantine is denied to everyone because its whole purpose
// is withholding content from readers; a removal is the opposite, content
// its own author put in the bin, and they have to be able to see what is in
// there to get it back out. Everyone else, moderators included, is denied
// here and reads the projected summary through `listQuarantined` instead.
//
// `list` works because My Signals queries this filtered by
// `data.reporter`, so every document the query can return already
// satisfies the rule. A query without that filter is denied outright,
// which is the point.
//
// Writes are server-only. `signalRemoval` moves documents in and out
// through the Admin SDK, exactly like the moderation callables — a client
// that could write here could restore a signal without the fan-out guard,
// or forge one that was never theirs.
match /removedSignals/{removalId} {
allow get, list: if request.auth != null
&& resource.data.data.reporter == userDoc();
allow create, update, delete: if false;
}
}
}